native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add multiple user-defined scripts per repository (#246)

* Add ScriptKind, ScriptDefinition, and refactor BlockingScriptKind

Introduce the foundational data model for multiple running scripts:

- ScriptKind enum with predefined types (run, debug, test, deploy,
custom), each carrying default icon, color, and name.
- ScriptDefinition struct with id, kind, name, systemImage, tintColor,
and command fields.
- Move TerminalTabTintColor to SupacodeSettingsShared and extend with
blue, purple, yellow, and teal cases for new script types.
- Refactor BlockingScriptKind: replace .run with .script(ScriptDefinition)
to support multiple concurrent user-defined scripts while keeping
.archive and .delete as lifecycle-bound cases.
- Add scripts and selectedScriptID fields to RepositorySettings with
backward-compatible migration from the legacy runScript field.
- Update all downstream switch sites, terminal state tracking, and tests.

* Add keyboard shortcuts for test, debug, and deploy scripts

Register testScript (Cmd+Shift+U), debugScript (Cmd+Shift+Y), and
deployScript (Cmd+Shift+D) in AppShortcutID with stable keys, display
names, and Ghostty unbind support.

Add corresponding menu items in WorktreeCommands and wire focused
value actions through WorktreeDetailView. Actions are currently nil
placeholders — they will be connected to AppFeature in a later commit.

* Add scripts settings table and expandable repo sidebar

Split repository settings into General and Scripts sub-sections via
expandable DisclosureGroup in the sidebar. The Scripts section shows a
list-based editor where each row has a ScriptKind picker, name field,
and monospaced command field with add/remove/reorder support.

Remove the old single "Run Script" ScriptSection from the General
settings — lifecycle scripts (setup, archive, delete) stay as-is.

Add reducer tests for addScript, removeScripts, and moveScripts
actions covering edge cases (remove middle, move to end, move to
beginning).

* Add split-button toolbar and command palette for scripts

Replace RunScriptToolbarButton with ScriptSplitButton: primary button
runs the selected script, chevron dropdown lists all scripts with
per-type icons and run/stop per-script. Cmd+. only stops .run kind.

Rewrite AppFeature script state: replace selectedRunScript/runScriptDraft
with scripts array and selectedScriptID. Wire testScript/debugScript/
deployScript actions to run the first matching script kind.

Add command palette entries: "Run: <name>" and "Stop: <name>" generated
dynamically from repository scripts. Selected script shows Cmd+R badge.

Replace runScriptWorktreeIDs with runningScriptsByWorktreeID dictionary
for per-definition-ID tracking. Remove RunScriptPromptView — scripts
are now configured via Settings.

* Add color-cycling sidebar indicator for running scripts

Replace the single-color boolean PingDot with MultiColorPingDot that
cycles through tint colors of all running script types per worktree.
Uses TimelineView for smooth color transitions and falls back to a
static dot when accessibilityReduceMotion is enabled.

Add scriptTintColorByID cache to RepositoriesFeature.State for
efficient color lookup without passing full ScriptDefinition arrays
through the view hierarchy. Cache is maintained alongside
runningScriptsByWorktreeID on script start, completion, and pruning.

* Fix review findings: encode compat, decode resilience, and cleanup

- Add custom encode(to:) deriving runScript from first .run script
for backward compatibility with older app versions.
- Guard against running the same script definition twice concurrently.
- Navigate to repositoryScripts settings (not General) when no scripts
are configured and user presses Cmd+R.
- Use try? for scripts decode so unknown ScriptKind values from future
versions fall back gracefully instead of crashing.
- Remove selectedScriptID entirely — primary toolbar button always
runs the first .run-kind script, matching the "Open In" pattern.
- Enforce at most one script per predefined kind in settings (only
.custom allows duplicates).
- Add Codable migration tests: legacy decode, dual-key decode,
encode round-trip, and unknown-kind resilience.

* Add lint/format script types, lock kind at creation, displayName

Add .lint and .format cases to ScriptKind with dedicated SF Symbols
and colors. Add displayName computed property to ScriptDefinition:
predefined types use their kind name, custom types use user name.

Lock script kind at creation: the + button opens a Menu showing only
unused predefined kinds plus always-available custom. Kind picker
replaced with static icon in script rows. Uniqueness enforced at
insertion in addScript(ScriptKind). Move TerminalTabTintColor.color
to shared module so SupacodeSettingsFeature can access it.

Add lintScript/formatScript keyboard shortcuts (Cmd+Shift+L/F),
menu items, and focused value wiring.

* Restructure scripts settings, toolbar labels, and cleanup

Remove debug script kind. Add resolvedSystemImage/resolvedTintColor
to ScriptDefinition so non-custom scripts always derive visuals from
ScriptKind defaults rather than persisted values.

Add Image.tintedSymbol helper for colored SF Symbols in macOS menus
(auto-resolves .fill variants via AppKit palette colors). Use tinted
icons in toolbar script dropdown, plain icons in menu bar.

Hide toolbar dropdown chevron when only a .run script is configured.
Fix DisclosureGroup: auto-expands on selection, allows manual collapse.
Navigate to Scripts settings tab (not General) from toolbar and Cmd+R.

Move lifecycle scripts to Scripts tab. Remove non-run/stop keyboard
shortcuts entirely (deferred to future iteration). Extend Makefile
and swiftlint to cover SupacodeSettingsShared/SupacodeSettingsFeature.

* Fix decode resilience, dedup, tests, and cleanup

* Fix opening brace lint violations in persistence extensions

* Fix identity, kind defaults, and dead code cleanup

* Remove tint color cache, fix displayName, encode fallback, DRY

- Remove `scriptTintColorByID` side-cache from RepositoriesFeature;
resolve colors from current script definitions via environment.
- Use `displayName` in BlockingScriptKind.tabTitle and settings header.
- Encode `runScript` as empty string when no .run scripts exist.
- Distinguish absent vs corrupted `scripts` key in lossy decoder.
- Document `runScript` as legacy backward-compat field.
- Extract duplicated `shortcutDisplay` into shared free function.
- Add test for encode fallback with no .run-kind script.
- Update RepositorySettingsKeyTests to use setupScript for round-trips.

* Fix cross-repo indicator bug, DRY violations, and runScript safety

- Fix sidebar indicator disappearing for scripts running in non-selected
repositories by falling back to .green when script ID lookup fails
- Refactor .binding case to reuse persistAndNotify, removing duplication
- Extract scriptButton helper in ScriptSplitButton to DRY three branches
- Extract LifecycleScriptSection in RepositoryScriptsSettingsView
- Make runScript property private(set) to prevent accidental direct mutation
- Clarify stopRunScripts intent with expanded doc comments

* Optimize script color lookup, add duplicate-run test, fix help text dots

- Pre-compute scriptsByID dictionary once in environment instead of
rebuilding per sidebar row in runningScriptColors
- Add test verifying duplicate runNamedScript is silently rejected
- Add trailing dots to help text strings for convention consistency

* Add confirmation dialog for script deletion

Show an alert before removing a user-defined script in repository
settings. Also extract SettingsView subviews to fix type-checker
timeout caused by the new @Presents alert state.

* Toggle disclosure on tap when repository is already selected

* Replace split buttons with Menu primaryAction for open and script toolbars

* Show menu indicator on open and script toolbar menus

* Populate supacode repo-specific scripts

* Address review findings from argue-review debate

- Extract duplicate isExpanded Binding to local let in SettingsView
- Update toolbar placeholder to match live ScriptMenu (play instead of play.fill, Label instead of HStack)
- Guard against empty shortcut display in resolveShortcutDisplay
- Use fallback label in ScriptMenu when shortcut resolves to empty
- Use stroke-only stop icon consistently (stop instead of stop.fill)
- Make Divider conditional on non-empty scripts in ScriptMenu dropdown
- Fix doc comments for ScriptMenu and primaryScript

authored by

Stefano Bertagno and committed by
GitHub
1f38a0c1 bfcd2f05

+2033 -663
+3
.swiftlint.yml
··· 4 4 - supacode 5 5 - supacode-cli 6 6 - supacodeTests 7 + - SupacodeSettingsShared 8 + - SupacodeSettingsFeature 7 9 excluded: 8 10 - ThirdParty/ghostty 9 11 # Skill content contains long markdown lines inside multiline strings. 10 12 - supacode/Features/Settings/BusinessLogic/CLISkillContent.swift 13 + - SupacodeSettingsShared/BusinessLogic/CLISkillContent.swift 11 14 12 15 disabled_rules: 13 16 - file_length
+2 -2
Makefile
··· 117 117 xcodebuild test -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -parallel-testing-enabled NO; \ 118 118 fi 119 119 120 - format: # Format code with swift format (local only) 121 - swift format -p --in-place --recursive --configuration ./.swift-format.json supacode supacode-cli supacodeTests 120 + format: # Format code with swift format (local only). 121 + swift format -p --in-place --recursive --configuration ./.swift-format.json supacode supacode-cli supacodeTests SupacodeSettingsShared SupacodeSettingsFeature 122 122 123 123 lint: # Lint code with swiftlint 124 124 mise exec -- swiftlint lint --quiet --config .swiftlint.yml
+56 -9
SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift
··· 27 27 ) 28 28 } 29 29 30 + @Presents public var alert: AlertState<Alert>? 31 + 30 32 public init( 31 33 rootURL: URL, 32 34 settings: RepositorySettings, ··· 52 54 } 53 55 } 54 56 57 + @CasePathable 58 + public enum Alert: Equatable { 59 + case confirmRemoveScript(ScriptDefinition.ID) 60 + } 61 + 55 62 public enum Action: BindableAction { 56 63 case task 57 64 case settingsLoaded( ··· 63 70 globalPullRequestMergeStrategy: PullRequestMergeStrategy 64 71 ) 65 72 case branchDataLoaded([String], defaultBaseRef: String) 73 + case addScript(ScriptKind) 74 + case removeScript(ScriptDefinition.ID) 75 + case alert(PresentationAction<Alert>) 66 76 case delegate(Delegate) 67 77 case binding(BindingAction<State>) 68 78 } ··· 160 170 state.isBranchDataLoaded = true 161 171 return .none 162 172 173 + case .addScript(let kind): 174 + // Predefined kinds are unique; reject duplicates. 175 + guard kind == .custom || !state.settings.scripts.contains(where: { $0.kind == kind }) else { 176 + return .none 177 + } 178 + state.settings.scripts.append(ScriptDefinition(kind: kind)) 179 + return persistAndNotify(state: &state) 180 + 181 + case .removeScript(let id): 182 + guard let script = state.settings.scripts.first(where: { $0.id == id }) else { return .none } 183 + state.alert = AlertState { 184 + TextState("Remove \"\(script.displayName)\" script?") 185 + } actions: { 186 + ButtonState(role: .destructive, action: .confirmRemoveScript(id)) { 187 + TextState("Remove") 188 + } 189 + ButtonState(role: .cancel) { 190 + TextState("Cancel") 191 + } 192 + } message: { 193 + TextState("This action cannot be undone.") 194 + } 195 + return .none 196 + 197 + case .alert(.presented(.confirmRemoveScript(let id))): 198 + state.settings.scripts.removeAll { $0.id == id } 199 + return persistAndNotify(state: &state) 200 + 201 + case .alert: 202 + return .none 203 + 163 204 case .binding: 164 205 if state.isBareRepository { 165 206 state.settings.copyIgnoredOnWorktreeCreate = nil 166 207 state.settings.copyUntrackedOnWorktreeCreate = nil 167 208 } 168 - let rootURL = state.rootURL 169 - var normalizedSettings = state.settings 170 - normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( 171 - normalizedSettings.worktreeBaseDirectoryPath, 172 - repositoryRootURL: rootURL 173 - ) 174 - @Shared(.repositorySettings(rootURL)) var repositorySettings 175 - $repositorySettings.withLock { $0 = normalizedSettings } 176 - return .send(.delegate(.settingsChanged(rootURL))) 209 + return persistAndNotify(state: &state) 177 210 178 211 case .delegate: 179 212 return .none 180 213 } 181 214 } 215 + .ifLet(\.$alert, action: \.alert) 216 + } 217 + 218 + /// Persists the current settings and notifies the delegate. 219 + private func persistAndNotify(state: inout State) -> Effect<Action> { 220 + let rootURL = state.rootURL 221 + var normalizedSettings = state.settings 222 + normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( 223 + normalizedSettings.worktreeBaseDirectoryPath, 224 + repositoryRootURL: rootURL 225 + ) 226 + @Shared(.repositorySettings(rootURL)) var repositorySettings 227 + $repositorySettings.withLock { $0 = normalizedSettings } 228 + return .send(.delegate(.settingsChanged(rootURL))) 182 229 } 183 230 }
+1 -1
SupacodeSettingsFeature/Reducer/SettingsFeature.swift
··· 546 546 state.repositorySettings = nil 547 547 return 548 548 } 549 - guard case .repository(let repositoryID) = selection else { 549 + guard let repositoryID = selection.repositoryID else { 550 550 state.repositorySettings = nil 551 551 return 552 552 }
+1 -1
SupacodeSettingsFeature/Views/AppearanceOptionCardView.swift
··· 1 - import SwiftUI 2 1 import SupacodeSettingsShared 2 + import SwiftUI 3 3 4 4 struct AppearanceOptionCardView: View { 5 5 let mode: AppearanceMode
+145
SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift
··· 1 + import ComposableArchitecture 2 + import SupacodeSettingsShared 3 + import SwiftUI 4 + 5 + /// Settings sub-section for managing on-demand and lifecycle scripts. 6 + public struct RepositoryScriptsSettingsView: View { 7 + @Bindable var store: StoreOf<RepositorySettingsFeature> 8 + 9 + public init(store: StoreOf<RepositorySettingsFeature>) { 10 + self.store = store 11 + } 12 + 13 + public var body: some View { 14 + Form { 15 + // Lifecycle scripts. 16 + LifecycleScriptSection( 17 + text: $store.settings.setupScript, 18 + title: "Setup Script", 19 + subtitle: "Runs once after worktree creation.", 20 + icon: "truck.box.badge.clock", 21 + iconColor: .blue, 22 + footerExample: "pnpm install" 23 + ) 24 + LifecycleScriptSection( 25 + text: $store.settings.archiveScript, 26 + title: "Archive Script", 27 + subtitle: "Runs before a worktree is archived.", 28 + icon: "archivebox", 29 + iconColor: .orange, 30 + footerExample: "docker compose down" 31 + ) 32 + LifecycleScriptSection( 33 + text: $store.settings.deleteScript, 34 + title: "Delete Script", 35 + subtitle: "Runs before a worktree is deleted.", 36 + icon: "trash", 37 + iconColor: .red, 38 + footerExample: "docker compose down" 39 + ) 40 + 41 + // User-defined scripts, each in its own section. 42 + ForEach($store.settings.scripts) { $script in 43 + Section { 44 + if script.kind == .custom { 45 + TextField("Name", text: $script.name) 46 + } 47 + ScriptCommandEditor(text: $script.command, label: script.displayName) 48 + Button("Remove Script…", role: .destructive) { 49 + store.send(.removeScript(script.id)) 50 + } 51 + .buttonStyle(.plain) 52 + .foregroundStyle(.red) 53 + .help("Remove this script.") 54 + } header: { 55 + Label { 56 + Text("\(script.displayName) Script") 57 + .font(.body) 58 + .bold() 59 + } icon: { 60 + Image(systemName: script.resolvedSystemImage).foregroundStyle(script.resolvedTintColor.color) 61 + .accessibilityHidden(true) 62 + }.labelStyle(.verticallyCentered) 63 + } 64 + } 65 + 66 + } 67 + .alert($store.scope(state: \.alert, action: \.alert)) 68 + .formStyle(.grouped) 69 + .padding(.top, -20) 70 + .padding(.leading, -8) 71 + .padding(.trailing, -6) 72 + .toolbar { 73 + ToolbarItem(placement: .primaryAction) { 74 + let usedKinds = Set(store.settings.scripts.map(\.kind)) 75 + Menu { 76 + ForEach(ScriptKind.allCases, id: \.self) { kind in 77 + if kind == .custom || !usedKinds.contains(kind) { 78 + Button { 79 + store.send(.addScript(kind)) 80 + } label: { 81 + Label { 82 + Text("\(kind.defaultName) Script") 83 + } icon: { 84 + Image.tintedSymbol(kind.defaultSystemImage, color: kind.defaultTintColor.nsColor) 85 + } 86 + } 87 + } 88 + } 89 + } label: { 90 + Image(systemName: "plus") 91 + .accessibilityLabel("Add Script") 92 + } 93 + .help("Add a new script.") 94 + } 95 + } 96 + } 97 + } 98 + 99 + /// Reusable section for lifecycle scripts (setup, archive, delete). 100 + private struct LifecycleScriptSection: View { 101 + @Binding var text: String 102 + let title: String 103 + let subtitle: String 104 + let icon: String 105 + let iconColor: Color 106 + let footerExample: String 107 + 108 + var body: some View { 109 + Section { 110 + ScriptCommandEditor(text: $text, label: title) 111 + } header: { 112 + Label { 113 + VStack(alignment: .leading, spacing: 0) { 114 + Text(title) 115 + .font(.body) 116 + .bold() 117 + .lineLimit(1) 118 + Text(subtitle) 119 + .font(.footnote) 120 + .foregroundStyle(.secondary) 121 + .lineLimit(1) 122 + } 123 + } icon: { 124 + Image(systemName: icon).foregroundStyle(iconColor).accessibilityHidden(true) 125 + }.labelStyle(.verticallyCentered) 126 + } footer: { 127 + Text("e.g., `\(footerExample)`") 128 + } 129 + } 130 + } 131 + 132 + /// Monospaced text editor for script commands. 133 + private struct ScriptCommandEditor: View { 134 + @Binding var text: String 135 + let label: String 136 + 137 + var body: some View { 138 + TextEditor(text: $text) 139 + .monospaced() 140 + .textEditorStyle(.plain) 141 + .autocorrectionDisabled() 142 + .frame(height: 90) 143 + .accessibilityLabel(label) 144 + } 145 + }
-49
SupacodeSettingsFeature/Views/RepositorySettingsView.swift
··· 108 108 description: "Path to the repository root." 109 109 ) 110 110 } 111 - ScriptSection( 112 - title: "Setup Script", 113 - subtitle: "Runs once after worktree creation.", 114 - text: settings.setupScript, 115 - placeholder: "claude --dangerously-skip-permissions" 116 - ) 117 - ScriptSection( 118 - title: "Run Script", 119 - subtitle: "Launched on demand from the toolbar.", 120 - text: settings.runScript, 121 - placeholder: "npm run dev" 122 - ) 123 - ScriptSection( 124 - title: "Archive Script", 125 - subtitle: "Runs before a worktree is archived.", 126 - text: settings.archiveScript, 127 - placeholder: "docker compose down" 128 - ) 129 - ScriptSection( 130 - title: "Delete Script", 131 - subtitle: "Runs before a worktree is deleted.", 132 - text: settings.deleteScript, 133 - placeholder: "docker compose down" 134 - ) 135 111 } 136 112 .formStyle(.grouped) 137 113 .padding(.top, -20) ··· 139 115 .padding(.trailing, -6) 140 116 .task { 141 117 store.send(.task) 142 - } 143 - } 144 - } 145 - 146 - // MARK: - Script section. 147 - 148 - private struct ScriptSection: View { 149 - let title: String 150 - let subtitle: String 151 - let text: Binding<String> 152 - let placeholder: String 153 - 154 - var body: some View { 155 - Section { 156 - TextEditor(text: text) 157 - .monospaced() 158 - .textEditorStyle(.plain) 159 - .autocorrectionDisabled() 160 - .frame(height: 112) 161 - .accessibilityLabel(title) 162 - } header: { 163 - Text(title) 164 - Text(subtitle) 165 - } footer: { 166 - Text("e.g., `\(placeholder)`") 167 118 } 168 119 } 169 120 }
+11
SupacodeSettingsFeature/Views/SettingsSection.swift
··· 9 9 case updates 10 10 case github 11 11 case repository(String) 12 + case repositoryScripts(String) 13 + 14 + /// The repository ID for repository-scoped sections. 15 + public var repositoryID: String? { 16 + switch self { 17 + case .repository(let id), .repositoryScripts(let id): 18 + id 19 + default: 20 + nil 21 + } 22 + } 12 23 }
+26 -26
SupacodeSettingsShared/App/AppShortcutOverride.swift
··· 32 32 33 33 // MARK: - SwiftUI conversions. 34 34 35 - public extension AppShortcutOverride { 36 - init(from eventModifiers: SwiftUI.EventModifiers, keyCode: UInt16) { 35 + extension AppShortcutOverride { 36 + public init(from eventModifiers: SwiftUI.EventModifiers, keyCode: UInt16) { 37 37 self.keyCode = keyCode 38 38 var flags: ModifierFlags = [] 39 39 if eventModifiers.contains(.command) { flags.insert(.command) } ··· 44 44 self.isEnabled = true 45 45 } 46 46 47 - var eventModifiers: SwiftUI.EventModifiers { 47 + public var eventModifiers: SwiftUI.EventModifiers { 48 48 var result: SwiftUI.EventModifiers = [] 49 49 if modifiers.contains(.command) { result.insert(.command) } 50 50 if modifiers.contains(.option) { result.insert(.option) } ··· 53 53 return result 54 54 } 55 55 56 - var keyboardShortcut: KeyboardShortcut { 56 + public var keyboardShortcut: KeyboardShortcut { 57 57 KeyboardShortcut(keyEquivalent, modifiers: eventModifiers) 58 58 } 59 59 60 - var keyEquivalent: KeyEquivalent { 60 + public var keyEquivalent: KeyEquivalent { 61 61 Self.keyEquivalent(for: keyCode) 62 62 } 63 63 } 64 64 65 65 // MARK: - Display. 66 66 67 - public extension AppShortcutOverride { 68 - var displayString: String { 67 + extension AppShortcutOverride { 68 + public var displayString: String { 69 69 Self.displaySymbols(for: keyCode, modifiers: modifiers).joined() 70 70 } 71 71 72 72 // Ordered array of individual display symbols: one per modifier, followed by the key. 73 - var displaySymbols: [String] { 73 + public var displaySymbols: [String] { 74 74 Self.displaySymbols(for: keyCode, modifiers: modifiers) 75 75 } 76 76 77 - static func displaySymbols(for keyCode: UInt16, modifiers: ModifierFlags) -> [String] { 77 + public static func displaySymbols(for keyCode: UInt16, modifiers: ModifierFlags) -> [String] { 78 78 var parts: [String] = [] 79 79 if modifiers.contains(.command) { parts.append("⌘") } 80 80 if modifiers.contains(.shift) { parts.append("⇧") } ··· 87 87 88 88 // MARK: - System hotkeys. 89 89 90 - public extension AppShortcutOverride { 90 + extension AppShortcutOverride { 91 91 // Well-known macOS app conventions always reserved by AppKit (not in the symbolic hotkeys plist). 92 - static let appKitReservedDisplayStrings: Set<String> = ["⌘Q", "⌘W", "⌘H", "⌘M"] 92 + public static let appKitReservedDisplayStrings: Set<String> = ["⌘Q", "⌘W", "⌘H", "⌘M"] 93 93 94 94 // Reads macOS system symbolic hotkeys at runtime and returns their display strings, 95 95 // combined with well-known AppKit reserved shortcuts. 96 - static func allReservedDisplayStrings() -> Set<String> { 96 + public static func allReservedDisplayStrings() -> Set<String> { 97 97 systemReservedDisplayStrings().union(appKitReservedDisplayStrings) 98 98 } 99 99 100 100 // Reads macOS system symbolic hotkeys at runtime and returns their display strings. 101 - static func systemReservedDisplayStrings() -> Set<String> { 101 + public static func systemReservedDisplayStrings() -> Set<String> { 102 102 guard let defaults = UserDefaults(suiteName: "com.apple.symbolichotkeys"), 103 103 let hotkeys = defaults.dictionary(forKey: "AppleSymbolicHotKeys") 104 104 else { ··· 131 131 132 132 // MARK: - Ghostty keybind. 133 133 134 - public extension AppShortcutOverride { 135 - var ghosttyKeybind: String { 134 + extension AppShortcutOverride { 135 + public var ghosttyKeybind: String { 136 136 let parts = ghosttyModifierParts + [Self.ghosttyKeyName(for: keyCode)] 137 137 return parts.joined(separator: "+") 138 138 } ··· 151 151 152 152 private nonisolated let shortcutLogger = SupaLogger("Shortcuts") 153 153 154 - public extension AppShortcutOverride { 154 + extension AppShortcutOverride { 155 155 // Reverse lookup: given a US QWERTY character, return its key code. 156 - static func keyCode(for character: Character) -> UInt16? { 156 + public static func keyCode(for character: Character) -> UInt16? { 157 157 reverseUSQwerty[character] 158 158 } 159 159 ··· 167 167 168 168 // Resolves the character for a key code using the current keyboard layout, 169 169 // falling back to US QWERTY when the layout is unavailable (e.g., CI, sandboxed contexts). 170 - static func layoutCharacter(for code: UInt16) -> String? { 170 + public static func layoutCharacter(for code: UInt16) -> String? { 171 171 if let char = currentLayoutCharacter(for: code, modifierState: 0) { return char } 172 172 shortcutLogger.debug("Using US QWERTY fallback for key code \(code)") 173 173 return usQwertyFallback[code] 174 174 } 175 175 176 - static func displayCharacter(for keyEquivalent: KeyEquivalent) -> String { 176 + public static func displayCharacter(for keyEquivalent: KeyEquivalent) -> String { 177 177 guard let code = keyCode(forDisplayedKeyEquivalent: keyEquivalent.character) else { 178 178 return String(keyEquivalent.character).uppercased() 179 179 } ··· 181 181 } 182 182 183 183 // The Ghostty key name for a given key code (e.g. "a", "arrow_up", "return"). 184 - static func resolvedGhosttyKeyName(for code: UInt16) -> String { 184 + public static func resolvedGhosttyKeyName(for code: UInt16) -> String { 185 185 ghosttyKeyName(for: code) 186 186 } 187 187 ··· 269 269 270 270 // AppKit renders menu key equivalents from the logical key equivalent. Reverse 271 271 // lookup the active layout so our own labels match the menu bar. 272 - static func keyCode( 272 + public static func keyCode( 273 273 forDisplayedKeyEquivalent character: Character, 274 274 candidateKeyCodes: [UInt16] = candidatePrintableKeyCodes, 275 275 modifierStates: [UInt32] = menuDisplayModifierStates, ··· 288 288 return nil 289 289 } 290 290 291 - static func keyCode(forDisplayedKeyEquivalent character: Character) -> UInt16? { 291 + public static func keyCode(forDisplayedKeyEquivalent character: Character) -> UInt16? { 292 292 keyCode(forDisplayedKeyEquivalent: character) { code, modifierState in 293 293 currentLayoutCharacter(for: code, modifierState: modifierState) 294 294 } ··· 319 319 320 320 // UCKeyTranslate modifier states: unmodified, shift, option, shift+option. 321 321 // Ordered so the simplest printable mapping is preferred during reverse lookup. 322 - static let menuDisplayModifierStates: [UInt32] = [0, 0x02, 0x08, 0x0A] 323 - static let candidatePrintableKeyCodes: [UInt16] = Array(usQwertyFallback.keys).sorted() 322 + public static let menuDisplayModifierStates: [UInt32] = [0, 0x02, 0x08, 0x0A] 323 + public static let candidatePrintableKeyCodes: [UInt16] = Array(usQwertyFallback.keys).sorted() 324 324 325 325 private static func ghosttyKeyName(for code: UInt16) -> String { 326 326 switch Int(code) { ··· 337 337 } 338 338 } 339 339 340 - static func displayCharacter(for code: UInt16, modifiers: ModifierFlags = []) -> String { 340 + public static func displayCharacter(for code: UInt16, modifiers: ModifierFlags = []) -> String { 341 341 switch Int(code) { 342 342 case kVK_LeftArrow: return "←" 343 343 case kVK_RightArrow: return "→" ··· 357 357 } 358 358 } 359 359 360 - static func keyEquivalent(for code: UInt16) -> KeyEquivalent { 360 + public static func keyEquivalent(for code: UInt16) -> KeyEquivalent { 361 361 switch Int(code) { 362 362 case kVK_LeftArrow: return .leftArrow 363 363 case kVK_RightArrow: return .rightArrow
+5 -3
SupacodeSettingsShared/App/AppShortcuts.swift
··· 342 342 AppShortcutGroup(category: .worktreeSelection, shortcuts: worktreeSelection), 343 343 AppShortcutGroup( 344 344 category: .actions, 345 - shortcuts: [openWorktree, revealInFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript] 345 + shortcuts: [ 346 + openWorktree, revealInFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript, 347 + ] 346 348 ), 347 349 ] 348 350 ··· 407 409 408 410 // MARK: - View modifier. 409 411 410 - public extension View { 412 + extension View { 411 413 @ViewBuilder 412 - func appKeyboardShortcut(_ shortcut: AppShortcut?) -> some View { 414 + public func appKeyboardShortcut(_ shortcut: AppShortcut?) -> some View { 413 415 if let shortcut { 414 416 self.keyboardShortcut(shortcut.keyEquivalent, modifiers: shortcut.modifiers) 415 417 } else {
+5 -5
SupacodeSettingsShared/App/KeyboardShortcut+Display.swift
··· 1 1 import SwiftUI 2 2 3 - public extension KeyboardShortcut { 4 - var displaySymbols: [String] { 3 + extension KeyboardShortcut { 4 + public var displaySymbols: [String] { 5 5 var parts: [String] = [] 6 6 if modifiers.contains(.command) { parts.append("⌘") } 7 7 if modifiers.contains(.shift) { parts.append("⇧") } ··· 11 11 return parts 12 12 } 13 13 14 - var display: String { 14 + public var display: String { 15 15 displaySymbols.joined() 16 16 } 17 17 } 18 18 19 - public extension KeyEquivalent { 20 - var display: String { 19 + extension KeyEquivalent { 20 + public var display: String { 21 21 switch self { 22 22 case .delete: "⌫" 23 23 case .return: "↩"
+1 -1
SupacodeSettingsShared/BusinessLogic/ClaudeHookSettings.swift
··· 32 32 "UserPromptSubmit": [ 33 33 .init(hooks: [ 34 34 .init(command: ClaudeHookSettings.busyOn, timeout: 10) 35 - ]), 35 + ]) 36 36 ], 37 37 "Stop": [ 38 38 .init(hooks: [.init(command: ClaudeHookSettings.busyOff, timeout: 10)])
+2 -2
SupacodeSettingsShared/BusinessLogic/CodexHookSettings.swift
··· 33 33 "UserPromptSubmit": [ 34 34 .init(hooks: [ 35 35 .init(command: CodexHookSettings.busyOn, timeout: 10) 36 - ]), 36 + ]) 37 37 ], 38 38 "Stop": [ 39 39 .init(hooks: [.init(command: CodexHookSettings.busyOff, timeout: 10)]) ··· 48 48 let hooks: [String: [AgentHookGroup]] = [ 49 49 "Stop": [ 50 50 .init(hooks: [.init(command: CodexHookSettings.notify, timeout: 10)]) 51 - ], 51 + ] 52 52 ] 53 53 }
+4 -5
SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift
··· 94 94 continuation.resume() 95 95 } 96 96 } 97 - 98 - public nonisolated extension SharedReaderKey where Self == RepositoryRootsKey.Default { 99 - static var repositoryRoots: Self { 97 + nonisolated extension SharedReaderKey where Self == RepositoryRootsKey.Default { 98 + public static var repositoryRoots: Self { 100 99 Self[RepositoryRootsKey(), default: []] 101 100 } 102 101 } 103 102 104 - public nonisolated extension SharedReaderKey where Self == PinnedWorktreeIDsKey.Default { 105 - static var pinnedWorktreeIDs: Self { 103 + nonisolated extension SharedReaderKey where Self == PinnedWorktreeIDsKey.Default { 104 + public static var pinnedWorktreeIDs: Self { 106 105 Self[PinnedWorktreeIDsKey(), default: []] 107 106 } 108 107 }
+2 -3
SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift
··· 87 87 continuation.resume() 88 88 } 89 89 } 90 - 91 - public nonisolated extension SharedReaderKey where Self == RepositorySettingsKey.Default { 92 - static func repositorySettings(_ rootURL: URL) -> Self { 90 + nonisolated extension SharedReaderKey where Self == RepositorySettingsKey.Default { 91 + public static func repositorySettings(_ rootURL: URL) -> Self { 93 92 Self[RepositorySettingsKey(rootURL: rootURL), default: .default] 94 93 } 95 94 }
+7 -8
SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift
··· 36 36 public static var testValue: URL { SupacodePaths.settingsURL } 37 37 } 38 38 39 - public extension DependencyValues { 40 - nonisolated var settingsFileStorage: SettingsFileStorage { 39 + extension DependencyValues { 40 + public nonisolated var settingsFileStorage: SettingsFileStorage { 41 41 get { self[SettingsFileStorageKey.self] } 42 42 set { self[SettingsFileStorageKey.self] = newValue } 43 43 } 44 44 45 - nonisolated var settingsFileURL: URL { 45 + public nonisolated var settingsFileURL: URL { 46 46 get { self[SettingsFileURLKey.self] } 47 47 set { self[SettingsFileURLKey.self] = newValue } 48 48 } 49 49 } 50 50 51 - public extension SettingsFileStorage { 52 - nonisolated static func inMemory() -> SettingsFileStorage { 51 + extension SettingsFileStorage { 52 + public nonisolated static func inMemory() -> SettingsFileStorage { 53 53 let storage = InMemorySettingsFileStorage() 54 54 return SettingsFileStorage( 55 55 load: { try storage.load($0) }, ··· 154 154 return encoder 155 155 } 156 156 } 157 - 158 - public nonisolated extension SharedReaderKey where Self == SettingsFileKey.Default { 159 - static var settingsFile: Self { 157 + nonisolated extension SharedReaderKey where Self == SettingsFileKey.Default { 158 + public static var settingsFile: Self { 160 159 Self[SettingsFileKey(), default: .default] 161 160 } 162 161 }
+4 -4
SupacodeSettingsShared/Clients/Analytics/AnalyticsClient.swift
··· 39 39 ) 40 40 } 41 41 42 - public extension DependencyValues { 43 - var analyticsClient: AnalyticsClient { 42 + extension DependencyValues { 43 + public var analyticsClient: AnalyticsClient { 44 44 get { self[AnalyticsClient.self] } 45 45 set { self[AnalyticsClient.self] = newValue } 46 46 } ··· 50 50 static let defaultValue = AnalyticsClient.liveValue 51 51 } 52 52 53 - public extension EnvironmentValues { 54 - var analyticsClient: AnalyticsClient { 53 + extension EnvironmentValues { 54 + public var analyticsClient: AnalyticsClient { 55 55 get { self[AnalyticsClientKey.self] } 56 56 set { self[AnalyticsClientKey.self] = newValue } 57 57 }
+2 -2
SupacodeSettingsShared/Clients/CodingAgents/ClaudeSettingsClient.swift
··· 50 50 ) 51 51 } 52 52 53 - public extension DependencyValues { 54 - var claudeSettingsClient: ClaudeSettingsClient { 53 + extension DependencyValues { 54 + public var claudeSettingsClient: ClaudeSettingsClient { 55 55 get { self[ClaudeSettingsClient.self] } 56 56 set { self[ClaudeSettingsClient.self] = newValue } 57 57 }
+2 -2
SupacodeSettingsShared/Clients/CodingAgents/CodexSettingsClient.swift
··· 50 50 ) 51 51 } 52 52 53 - public extension DependencyValues { 54 - var codexSettingsClient: CodexSettingsClient { 53 + extension DependencyValues { 54 + public var codexSettingsClient: CodexSettingsClient { 55 55 get { self[CodexSettingsClient.self] } 56 56 set { self[CodexSettingsClient.self] = newValue } 57 57 }
+2 -2
SupacodeSettingsShared/Clients/Notifications/SystemNotificationClient.swift
··· 119 119 ) 120 120 } 121 121 122 - public extension DependencyValues { 123 - var systemNotificationClient: SystemNotificationClient { 122 + extension DependencyValues { 123 + public var systemNotificationClient: SystemNotificationClient { 124 124 get { self[SystemNotificationClient.self] } 125 125 set { self[SystemNotificationClient.self] = newValue } 126 126 }
+2 -2
SupacodeSettingsShared/Clients/Settings/ArchivedWorktreeDatesClient.swift
··· 55 55 ) 56 56 } 57 57 58 - public extension DependencyValues { 59 - var archivedWorktreeDatesClient: ArchivedWorktreeDatesClient { 58 + extension DependencyValues { 59 + public var archivedWorktreeDatesClient: ArchivedWorktreeDatesClient { 60 60 get { self[ArchivedWorktreeDatesClient.self] } 61 61 set { self[ArchivedWorktreeDatesClient.self] = newValue } 62 62 }
+2 -2
SupacodeSettingsShared/Clients/Settings/CLIInstallerClient.swift
··· 36 36 ) 37 37 } 38 38 39 - public extension DependencyValues { 40 - var cliInstallerClient: CLIInstallerClient { 39 + extension DependencyValues { 40 + public var cliInstallerClient: CLIInstallerClient { 41 41 get { self[CLIInstallerClient.self] } 42 42 set { self[CLIInstallerClient.self] = newValue } 43 43 }
+2 -2
SupacodeSettingsShared/Clients/Settings/CLISkillClient.swift
··· 30 30 ) 31 31 } 32 32 33 - public extension DependencyValues { 34 - var cliSkillClient: CLISkillClient { 33 + extension DependencyValues { 34 + public var cliSkillClient: CLISkillClient { 35 35 get { self[CLISkillClient.self] } 36 36 set { self[CLISkillClient.self] = newValue } 37 37 }
+2 -2
SupacodeSettingsShared/Clients/Settings/RepositorySettingsGitClient.swift
··· 31 31 ) 32 32 } 33 33 34 - public extension DependencyValues { 35 - var repositorySettingsGitClient: RepositorySettingsGitClient { 34 + extension DependencyValues { 35 + public var repositorySettingsGitClient: RepositorySettingsGitClient { 36 36 get { self[RepositorySettingsGitClient.self] } 37 37 set { self[RepositorySettingsGitClient.self] = newValue } 38 38 }
+2 -2
SupacodeSettingsShared/Clients/Shell/ShellClient.swift
··· 139 139 ) 140 140 } 141 141 142 - public extension DependencyValues { 143 - var shellClient: ShellClient { 142 + extension DependencyValues { 143 + public var shellClient: ShellClient { 144 144 get { self[ShellClient.self] } 145 145 set { self[ShellClient.self] = newValue } 146 146 }
+69 -2
SupacodeSettingsShared/Models/RepositorySettings.swift
··· 4 4 public var setupScript: String 5 5 public var archiveScript: String 6 6 public var deleteScript: String 7 - public var runScript: String 7 + /// Legacy field kept for backward-compatible JSON serialization. 8 + /// New code should use `scripts` instead. On encode, this is 9 + /// derived from the first `.run`-kind script's command. 10 + public private(set) var runScript: String 11 + public var scripts: [ScriptDefinition] 8 12 public var openActionID: String 9 13 public var worktreeBaseRef: String? 10 14 public var worktreeBaseDirectoryPath: String? ··· 17 21 case archiveScript 18 22 case deleteScript 19 23 case runScript 24 + case scripts 20 25 case openActionID 21 26 case worktreeBaseRef 22 27 case worktreeBaseDirectoryPath ··· 30 35 archiveScript: "", 31 36 deleteScript: "", 32 37 runScript: "", 38 + scripts: [], 33 39 openActionID: OpenWorktreeAction.automaticSettingsID, 34 40 worktreeBaseRef: nil, 35 41 worktreeBaseDirectoryPath: nil, 36 42 copyIgnoredOnWorktreeCreate: nil, 37 43 copyUntrackedOnWorktreeCreate: nil, 38 - pullRequestMergeStrategy: nil 44 + pullRequestMergeStrategy: nil, 39 45 ) 40 46 41 47 public init( ··· 43 49 archiveScript: String, 44 50 deleteScript: String, 45 51 runScript: String, 52 + scripts: [ScriptDefinition] = [], 46 53 openActionID: String, 47 54 worktreeBaseRef: String?, 48 55 worktreeBaseDirectoryPath: String? = nil, ··· 54 61 self.archiveScript = archiveScript 55 62 self.deleteScript = deleteScript 56 63 self.runScript = runScript 64 + self.scripts = scripts 57 65 self.openActionID = openActionID 58 66 self.worktreeBaseRef = worktreeBaseRef 59 67 self.worktreeBaseDirectoryPath = worktreeBaseDirectoryPath ··· 76 84 runScript = 77 85 try container.decodeIfPresent(String.self, forKey: .runScript) 78 86 ?? Self.default.runScript 87 + // Migrate legacy `runScript` into the new `scripts` array when 88 + // the `scripts` key is absent from persisted JSON. 89 + // Decode element-by-element so a single unknown `ScriptKind` 90 + // only drops that entry, not the entire array. 91 + let decodedScripts = Self.decodeScriptsLossily(from: container) 92 + if let decodedScripts { 93 + scripts = decodedScripts 94 + } else if !runScript.isEmpty { 95 + scripts = [ScriptDefinition(kind: .run, command: runScript)] 96 + } else { 97 + scripts = Self.default.scripts 98 + } 79 99 openActionID = 80 100 try container.decodeIfPresent(String.self, forKey: .openActionID) 81 101 ?? Self.default.openActionID ··· 92 112 pullRequestMergeStrategy = 93 113 try container.decodeIfPresent(PullRequestMergeStrategy.self, forKey: .pullRequestMergeStrategy) 94 114 ?? Self.default.pullRequestMergeStrategy 115 + } 116 + 117 + public func encode(to encoder: Encoder) throws { 118 + var container = encoder.container(keyedBy: CodingKeys.self) 119 + try container.encode(setupScript, forKey: .setupScript) 120 + try container.encode(archiveScript, forKey: .archiveScript) 121 + try container.encode(deleteScript, forKey: .deleteScript) 122 + // Derive `runScript` from the first `.run`-kind script's command 123 + // so older clients can still read the value. 124 + // Fall back to empty string (not the legacy `runScript` property) 125 + // so removing all `.run` scripts correctly signals removal to 126 + // older clients instead of leaking the stale legacy value. 127 + let derivedRunScript = scripts.first(where: { $0.kind == .run })?.command ?? "" 128 + try container.encode(derivedRunScript, forKey: .runScript) 129 + try container.encode(scripts, forKey: .scripts) 130 + try container.encode(openActionID, forKey: .openActionID) 131 + try container.encodeIfPresent(worktreeBaseRef, forKey: .worktreeBaseRef) 132 + try container.encodeIfPresent(worktreeBaseDirectoryPath, forKey: .worktreeBaseDirectoryPath) 133 + try container.encodeIfPresent(copyIgnoredOnWorktreeCreate, forKey: .copyIgnoredOnWorktreeCreate) 134 + try container.encodeIfPresent(copyUntrackedOnWorktreeCreate, forKey: .copyUntrackedOnWorktreeCreate) 135 + try container.encodeIfPresent(pullRequestMergeStrategy, forKey: .pullRequestMergeStrategy) 136 + } 137 + 138 + /// Decodes the `scripts` array element-by-element, silently 139 + /// skipping entries that fail (e.g. unknown `ScriptKind`). 140 + /// Returns `nil` when the key is absent (legacy JSON), or `[]` 141 + /// when the key is present but corrupted (e.g. `null`). 142 + private static func decodeScriptsLossily( 143 + from container: KeyedDecodingContainer<CodingKeys> 144 + ) -> [ScriptDefinition]? { 145 + guard container.contains(.scripts) else { return nil } 146 + guard let wrappers = try? container.decode([Lossy<ScriptDefinition>].self, forKey: .scripts) else { 147 + // Key exists but value is not a valid array (e.g. null or 148 + // wrong type). Return empty rather than triggering legacy 149 + // migration which would overwrite with stale data. 150 + return [] 151 + } 152 + return wrappers.compactMap { $0.value } 153 + } 154 + } 155 + 156 + /// Wrapper that always succeeds at the container level, 157 + /// capturing decode failures as `nil` instead of throwing. 158 + private nonisolated struct Lossy<T: Decodable & Sendable>: Decodable, Sendable { 159 + nonisolated let value: T? 160 + nonisolated init(from decoder: Decoder) throws { 161 + value = try? T(from: decoder) 95 162 } 96 163 }
+64
SupacodeSettingsShared/Models/ScriptDefinition.swift
··· 1 + import Foundation 2 + 3 + /// A user-configured script that can be run on demand from the 4 + /// toolbar, command palette, or keyboard shortcut. Each repository 5 + /// stores an ordered array of these in `RepositorySettings.scripts`. 6 + public nonisolated struct ScriptDefinition: Identifiable, Codable, Equatable, Hashable, Sendable { 7 + public var id: UUID 8 + public var kind: ScriptKind 9 + public var name: String 10 + public var command: String 11 + 12 + /// Per-instance overrides — only meaningful for `.custom` kinds. 13 + /// Predefined kinds always resolve to the kind default. 14 + public var systemImage: String? 15 + public var tintColor: TerminalTabTintColor? 16 + 17 + /// Display name for toolbar labels: predefined types show their 18 + /// kind name ("Run", "Test"), custom types show user-defined name. 19 + public nonisolated var displayName: String { 20 + kind == .custom ? name : kind.defaultName 21 + } 22 + 23 + /// Resolved SF Symbol name: predefined types always use the kind 24 + /// default so future icon changes propagate automatically. 25 + public nonisolated var resolvedSystemImage: String { 26 + kind == .custom ? (systemImage ?? kind.defaultSystemImage) : kind.defaultSystemImage 27 + } 28 + 29 + /// Resolved tint color: predefined types always use the kind 30 + /// default so future color changes propagate automatically. 31 + public nonisolated var resolvedTintColor: TerminalTabTintColor { 32 + kind == .custom ? (tintColor ?? kind.defaultTintColor) : kind.defaultTintColor 33 + } 34 + 35 + public nonisolated init( 36 + id: UUID = UUID(), 37 + kind: ScriptKind, 38 + name: String? = nil, 39 + systemImage: String? = nil, 40 + tintColor: TerminalTabTintColor? = nil, 41 + command: String = "" 42 + ) { 43 + self.id = id 44 + self.kind = kind 45 + self.name = name ?? kind.defaultName 46 + self.systemImage = systemImage 47 + self.tintColor = tintColor 48 + self.command = command 49 + } 50 + } 51 + 52 + // MARK: - Collection helpers 53 + 54 + extension [ScriptDefinition] { 55 + /// The first `.run`-kind script — the primary toolbar action. 56 + public var primaryScript: ScriptDefinition? { 57 + first { $0.kind == .run } 58 + } 59 + 60 + /// Whether any `.run`-kind script is currently running. 61 + public func hasRunningRunScript(in runningIDs: Set<UUID>) -> Bool { 62 + contains { $0.kind == .run && runningIDs.contains($0.id) } 63 + } 64 + }
+49
SupacodeSettingsShared/Models/ScriptKind.swift
··· 1 + import Foundation 2 + 3 + /// Identifies the semantic category of a user-defined script. 4 + /// Predefined kinds carry default icon, color, and name; `.custom` 5 + /// requires explicit values stored on the owning `ScriptDefinition`. 6 + public enum ScriptKind: String, Codable, CaseIterable, Hashable, Sendable { 7 + case run 8 + case test 9 + case deploy 10 + case lint 11 + case format 12 + case custom 13 + 14 + /// Default display name shown in UI when the user hasn't provided one. 15 + public nonisolated var defaultName: String { 16 + switch self { 17 + case .run: "Run" 18 + case .test: "Test" 19 + case .deploy: "Deploy" 20 + case .lint: "Lint" 21 + case .format: "Format" 22 + case .custom: "Custom" 23 + } 24 + } 25 + 26 + /// Default SF Symbol name for the script kind. 27 + public nonisolated var defaultSystemImage: String { 28 + switch self { 29 + case .run: "play" 30 + case .test: "play.diamond" 31 + case .deploy: "arrowshape.turn.up.forward" 32 + case .lint: "exclamationmark.triangle" 33 + case .format: "circle.dotted.circle" 34 + case .custom: "text.alignleft" 35 + } 36 + } 37 + 38 + /// Default tab tint color for the script kind. 39 + public nonisolated var defaultTintColor: TerminalTabTintColor { 40 + switch self { 41 + case .run: .green 42 + case .test: .yellow 43 + case .deploy: .red 44 + case .lint: .blue 45 + case .format: .teal 46 + case .custom: .purple 47 + } 48 + } 49 + }
+40
SupacodeSettingsShared/Models/TerminalTabTintColor.swift
··· 1 + import AppKit 2 + import SwiftUI 3 + 4 + /// Color token for terminal tab tint indicators, used in place of 5 + /// `Color` so that related types can remain `Equatable` and `Sendable`. 6 + public enum TerminalTabTintColor: String, Codable, CaseIterable, Hashable, Sendable { 7 + case green 8 + case orange 9 + case red 10 + case blue 11 + case purple 12 + case yellow 13 + case teal 14 + 15 + /// Resolved SwiftUI color for rendering. 16 + public var color: Color { 17 + switch self { 18 + case .green: .green 19 + case .orange: .orange 20 + case .red: .red 21 + case .blue: .blue 22 + case .purple: .purple 23 + case .yellow: .yellow 24 + case .teal: .teal 25 + } 26 + } 27 + 28 + /// Resolved AppKit color for use in NSImage tinting. 29 + public var nsColor: NSColor { 30 + switch self { 31 + case .green: .systemGreen 32 + case .orange: .systemOrange 33 + case .red: .systemRed 34 + case .blue: .systemBlue 35 + case .purple: .systemPurple 36 + case .yellow: .systemYellow 37 + case .teal: .systemTeal 38 + } 39 + } 40 + }
+36
SupacodeSettingsShared/Support/TintedSymbol.swift
··· 1 + import AppKit 2 + import SwiftUI 3 + 4 + extension Image { 5 + /// Creates a tinted SF Symbol image suitable for AppKit menu 6 + /// rendering. Works around the macOS quirk where `Menu` items 7 + /// strip SwiftUI color modifiers from SF Symbol icons. 8 + /// 9 + /// Automatically resolves the filled variant of the symbol 10 + /// (appending `.fill` to the name) when one exists, falling 11 + /// back to the original name. This means callers don't need 12 + /// to track both outline and filled symbol names separately. 13 + public static func tintedSymbol(_ name: String, color: NSColor) -> Image { 14 + let resolvedName = filledSymbolName(for: name) 15 + let config = NSImage.SymbolConfiguration(paletteColors: [color]) 16 + guard 17 + let base = NSImage(systemSymbolName: resolvedName, accessibilityDescription: nil), 18 + let tinted = base.withSymbolConfiguration(config) 19 + else { 20 + return Image(systemName: resolvedName) 21 + } 22 + tinted.isTemplate = false 23 + return Image(nsImage: tinted) 24 + } 25 + 26 + /// Returns the `.fill` variant of a symbol name if it exists, 27 + /// otherwise returns the original name unchanged. 28 + private static func filledSymbolName(for name: String) -> String { 29 + guard !name.hasSuffix(".fill") else { return name } 30 + let filled = "\(name).fill" 31 + guard NSImage(systemSymbolName: filled, accessibilityDescription: nil) != nil else { 32 + return name 33 + } 34 + return filled 35 + } 36 + }
+18
SupacodeSettingsShared/Support/VerticallyCenteredLabelStyle.swift
··· 1 + import SwiftUI 2 + 3 + /// A label style that arranges the icon and title 4 + /// horizontally with vertical center alignment. 5 + public struct VerticallyCenteredLabelStyle: LabelStyle { 6 + public init() {} 7 + 8 + public func makeBody(configuration: Configuration) -> some View { 9 + HStack(spacing: 6) { 10 + configuration.icon 11 + configuration.title 12 + } 13 + } 14 + } 15 + 16 + extension LabelStyle where Self == VerticallyCenteredLabelStyle { 17 + public static var verticallyCentered: VerticallyCenteredLabelStyle { .init() } 18 + }
+28 -1
supacode.json
··· 2 2 "archiveScript" : "", 3 3 "copyIgnoredOnWorktreeCreate" : true, 4 4 "copyUntrackedOnWorktreeCreate" : true, 5 + "deleteScript" : "", 5 6 "openActionID" : "xcode", 6 7 "pullRequestMergeStrategy" : "merge", 7 8 "runScript" : "make run-app", 8 - "setupScript" : "git submodule update -f --depth=1 --init --no-fetch -j 8 --progress\ncodex" 9 + "scripts" : [ 10 + { 11 + "command" : "make run-app", 12 + "id" : "B90F7467-8638-4FB6-9CFC-450207596267", 13 + "kind" : "run", 14 + "name" : "Run" 15 + }, 16 + { 17 + "command" : "make test", 18 + "id" : "D477CB4A-8BF5-46E7-B192-E22193DC0705", 19 + "kind" : "test", 20 + "name" : "Test" 21 + }, 22 + { 23 + "command" : "make check", 24 + "id" : "19B9B006-EEF2-4C96-9CC6-7DCCF9F3C09D", 25 + "kind" : "lint", 26 + "name" : "Lint" 27 + }, 28 + { 29 + "command" : "make format", 30 + "id" : "914F90DE-B782-4A76-9845-8A1A45803833", 31 + "kind" : "format", 32 + "name" : "Format" 33 + } 34 + ], 35 + "setupScript" : "git submodule update --recursive" 9 36 }
+17 -74
supacode/App/ContentView.swift
··· 6 6 // 7 7 8 8 import ComposableArchitecture 9 + import SupacodeSettingsShared 9 10 import SwiftUI 10 11 import UniformTypeIdentifiers 11 12 ··· 24 25 } 25 26 26 27 var body: some View { 27 - let isRunScriptPromptPresented = Binding( 28 - get: { store.isRunScriptPromptPresented }, 29 - set: { store.send(.runScriptPromptPresented($0)) } 30 - ) 31 - let runScriptDraft = Binding( 32 - get: { store.runScriptDraft }, 33 - set: { store.send(.runScriptDraftChanged($0)) } 34 - ) 35 28 NavigationSplitView(columnVisibility: $leftSidebarVisibility) { 36 29 SidebarView(store: repositoriesStore, terminalManager: terminalManager) 37 30 .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 320) ··· 40 33 } 41 34 .navigationSplitViewStyle(.automatic) 42 35 .disabled(!store.repositories.isInitialLoadComplete) 36 + .environment(\.scriptsByID, Dictionary(uniqueKeysWithValues: store.scripts.map { ($0.id, $0) })) 43 37 .environment(\.surfaceBackgroundOpacity, terminalManager.surfaceBackgroundOpacity()) 44 38 .onChange(of: scenePhase) { _, newValue in 45 39 store.send(.scenePhaseChanged(newValue)) ··· 75 69 ) { promptStore in 76 70 WorktreeCreationPromptView(store: promptStore) 77 71 } 78 - .sheet(isPresented: isRunScriptPromptPresented) { 79 - RunScriptPromptView( 80 - script: runScriptDraft, 81 - onCancel: { 82 - store.send(.runScriptPromptPresented(false)) 83 - }, 84 - onSaveAndRun: { 85 - store.send(.saveRunScriptAndRun) 86 - } 87 - ) 88 - } 89 72 .focusedSceneValue(\.toggleLeftSidebarAction, toggleLeftSidebar) 90 73 .focusedSceneValue(\.revealInSidebarAction, revealInSidebarAction) 91 74 .overlay { ··· 93 76 store: store.scope(state: \.commandPalette, action: \.commandPalette), 94 77 items: CommandPaletteFeature.commandPaletteItems( 95 78 from: store.repositories, 96 - ghosttyCommands: ghosttyShortcuts.commandPaletteEntries 79 + ghosttyCommands: ghosttyShortcuts.commandPaletteEntries, 80 + scripts: store.scripts, 81 + runningScriptIDs: store.runningScriptIDs 97 82 ) 98 83 ) 99 84 } ··· 120 105 121 106 } 122 107 108 + private struct ScriptsByIDEnvironmentKey: EnvironmentKey { 109 + static let defaultValue: [UUID: ScriptDefinition] = [:] 110 + } 111 + 112 + extension EnvironmentValues { 113 + /// Pre-computed lookup for sidebar row color resolution. 114 + var scriptsByID: [UUID: ScriptDefinition] { 115 + get { self[ScriptsByIDEnvironmentKey.self] } 116 + set { self[ScriptsByIDEnvironmentKey.self] = newValue } 117 + } 118 + } 119 + 123 120 private struct SurfaceBackgroundOpacityKey: EnvironmentKey { 124 121 static let defaultValue: Double = 1 125 122 } ··· 152 149 } 153 150 } 154 151 } 155 - 156 - private struct RunScriptPromptView: View { 157 - @Binding var script: String 158 - let onCancel: () -> Void 159 - let onSaveAndRun: () -> Void 160 - 161 - private var canSave: Bool { 162 - !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 163 - } 164 - 165 - var body: some View { 166 - VStack(alignment: .leading, spacing: 16) { 167 - VStack(alignment: .leading, spacing: 4) { 168 - Text("Run") 169 - .font(.title3) 170 - Text("Enter a command to run in this worktree. It will be saved to repository settings.") 171 - .foregroundStyle(.secondary) 172 - } 173 - 174 - ZStack(alignment: .topLeading) { 175 - PlainTextEditor( 176 - text: $script, 177 - isMonospaced: true 178 - ) 179 - .frame(minHeight: 160) 180 - if script.isEmpty { 181 - Text("npm run dev") 182 - .foregroundStyle(.secondary) 183 - .padding(.leading, 6) 184 - .font(.body.monospaced()) 185 - .allowsHitTesting(false) 186 - } 187 - } 188 - 189 - HStack { 190 - Spacer() 191 - Button("Cancel") { 192 - onCancel() 193 - } 194 - .keyboardShortcut(.cancelAction) 195 - .help("Cancel (Esc)") 196 - 197 - Button("Save and Run") { 198 - onSaveAndRun() 199 - } 200 - .keyboardShortcut(.defaultAction) 201 - .help("Save and Run (↩)") 202 - .disabled(!canSave) 203 - } 204 - } 205 - .padding(20) 206 - .frame(minWidth: 520) 207 - } 208 - }
+1
supacode/Clients/Terminal/TerminalClient.swift
··· 13 13 case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool, id: UUID? = nil) 14 14 case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) 15 15 case stopRunScript(Worktree) 16 + case stopScript(Worktree, definitionID: UUID) 16 17 case runBlockingScript(Worktree, kind: BlockingScriptKind, script: String) 17 18 case closeFocusedTab(Worktree) 18 19 case closeFocusedSurface(Worktree)
+1 -1
supacode/Commands/WorktreeCommands.swift
··· 101 101 .disabled(deleteWorktreeAction == nil) 102 102 Divider() 103 103 // Scripts. 104 - Button("Run Script", systemImage: "play") { 104 + Button("Run Script", systemImage: ScriptKind.run.defaultSystemImage) { 105 105 runScriptAction?() 106 106 } 107 107 .appKeyboardShortcut(run)
+80 -59
supacode/Features/App/Reducer/AppFeature.swift
··· 22 22 var updates = UpdatesFeature.State() 23 23 var commandPalette = CommandPaletteFeature.State() 24 24 var openActionSelection: OpenWorktreeAction = .finder 25 - var selectedRunScript: String = "" 26 - var runScriptDraft: String = "" 27 - var isRunScriptPromptPresented = false 25 + var scripts: [ScriptDefinition] = [] 28 26 var notificationIndicatorCount: Int = 0 29 27 var lastKnownSystemNotificationsEnabled: Bool 30 28 var pendingDeeplinks: [Deeplink] = [] ··· 40 38 self.settings = settings 41 39 lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled 42 40 } 41 + 42 + /// The script that the primary toolbar button should run. 43 + var primaryScript: ScriptDefinition? { 44 + scripts.primaryScript 45 + } 46 + 47 + /// Running script IDs for the currently selected worktree. 48 + var runningScriptIDs: Set<UUID> { 49 + guard let worktreeID = repositories.selectedWorktreeID else { return [] } 50 + return repositories.runningScriptsByWorktreeID[worktreeID] ?? [] 51 + } 52 + 53 + /// Whether any `.run`-kind script is currently running in the selected worktree. 54 + var hasRunningRunScript: Bool { 55 + scripts.hasRunningRunScript(in: runningScriptIDs) 56 + } 43 57 } 44 58 45 59 enum Action { ··· 58 72 case requestQuit 59 73 case newTerminal 60 74 case runScript 61 - case runScriptDraftChanged(String) 62 - case runScriptPromptPresented(Bool) 63 - case saveRunScriptAndRun 64 - case stopRunScript 75 + case runNamedScript(ScriptDefinition) 76 + case stopScript(ScriptDefinition) 77 + case stopRunScripts 65 78 case closeTab 66 79 case closeSurface 67 80 case startSearch ··· 142 155 let repositoryPersistence = repositoryPersistence 143 156 guard let worktree else { 144 157 state.openActionSelection = .finder 145 - state.selectedRunScript = "" 146 - state.runScriptDraft = "" 147 - state.isRunScriptPromptPresented = false 158 + state.scripts = [] 148 159 var effects: [Effect<Action>] = [ 149 160 .run { _ in 150 161 await terminalClient.send(.setSelectedWorktreeID(nil)) ··· 165 176 } 166 177 let rootURL = worktree.repositoryRootURL 167 178 let worktreeID = worktree.id 168 - state.runScriptDraft = "" 169 - state.isRunScriptPromptPresented = false 170 179 @Shared(.repositorySettings(rootURL)) var repositorySettings 171 180 let settings = repositorySettings 172 181 return .merge( ··· 202 211 repositories.flatMap { $0.worktrees.map(\.id) } 203 212 .filter { !archivedIDs.contains($0) || deleteScriptIDs.contains($0) } 204 213 ) 205 - state.repositories.runScriptWorktreeIDs.formIntersection(ids) 206 - let recencyIDs = CommandPaletteFeature.recencyRetentionIDs(from: repositories) 214 + state.repositories.runningScriptsByWorktreeID = state.repositories.runningScriptsByWorktreeID 215 + .filter { ids.contains($0.key) } 216 + let recencyIDs = CommandPaletteFeature.recencyRetentionIDs( 217 + from: repositories, 218 + scripts: state.scripts 219 + ) 207 220 let worktrees = state.repositories.worktreesForInfoWatcher() 208 221 var effects: [Effect<Action>] = [ 209 222 .send( ··· 412 425 } 413 426 414 427 case .runScript: 415 - guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { 416 - return .none 417 - } 418 - let trimmed = state.selectedRunScript.trimmingCharacters(in: .whitespacesAndNewlines) 419 - guard !trimmed.isEmpty else { 420 - if state.isRunScriptPromptPresented { 428 + // Find the selected or primary script and run it. 429 + guard let definition = state.primaryScript else { 430 + // No scripts configured — open repository scripts settings. 431 + guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { 421 432 return .none 422 433 } 423 - state.runScriptDraft = state.selectedRunScript 424 - state.isRunScriptPromptPresented = true 434 + let repositoryID = worktree.repositoryRootURL.path(percentEncoded: false) 435 + return .send(.settings(.setSelection(.repositoryScripts(repositoryID)))) 436 + } 437 + return .send(.runNamedScript(definition)) 438 + 439 + case .runNamedScript(let definition): 440 + guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { 425 441 return .none 426 442 } 427 - analyticsClient.capture("script_run", nil) 428 - state.repositories.runScriptWorktreeIDs.insert(worktree.id) 429 - let script = state.selectedRunScript 443 + // Prevent running the same script twice. 444 + guard !state.runningScriptIDs.contains(definition.id) else { return .none } 445 + let trimmed = definition.command.trimmingCharacters(in: .whitespacesAndNewlines) 446 + guard !trimmed.isEmpty else { return .none } 447 + analyticsClient.capture("script_run", ["kind": definition.kind.rawValue]) 448 + var ids = state.repositories.runningScriptsByWorktreeID[worktree.id] ?? [] 449 + ids.insert(definition.id) 450 + state.repositories.runningScriptsByWorktreeID[worktree.id] = ids 430 451 return .run { _ in 431 - await terminalClient.send(.runBlockingScript(worktree, kind: .run, script: script)) 432 - } 433 - 434 - case .runScriptDraftChanged(let script): 435 - state.runScriptDraft = script 436 - return .none 437 - 438 - case .runScriptPromptPresented(let isPresented): 439 - state.isRunScriptPromptPresented = isPresented 440 - if !isPresented { 441 - state.runScriptDraft = "" 452 + await terminalClient.send( 453 + .runBlockingScript(worktree, kind: .script(definition), script: definition.command) 454 + ) 442 455 } 443 - return .none 444 456 445 - case .saveRunScriptAndRun: 457 + case .stopScript(let definition): 446 458 guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { 447 - state.isRunScriptPromptPresented = false 448 - state.runScriptDraft = "" 449 459 return .none 450 460 } 451 - let script = state.runScriptDraft 452 - let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 453 - guard !trimmed.isEmpty else { 454 - return .none 461 + return .run { _ in 462 + await terminalClient.send(.stopScript(worktree, definitionID: definition.id)) 455 463 } 456 - let rootURL = worktree.repositoryRootURL 457 - @Shared(.repositorySettings(rootURL)) var repositorySettings 458 - $repositorySettings.withLock { $0.runScript = script } 459 - if state.settings.repositorySettings?.rootURL == rootURL { 460 - state.settings.repositorySettings?.settings.runScript = script 461 - } 462 - state.selectedRunScript = script 463 - state.isRunScriptPromptPresented = false 464 - state.runScriptDraft = "" 465 - return .send(.runScript) 466 464 467 - case .stopRunScript: 465 + case .stopRunScripts: 468 466 guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { 469 467 return .none 470 468 } ··· 551 549 settings.openActionID, 552 550 defaultEditorID: normalizedDefaultEditorID 553 551 ) 554 - state.selectedRunScript = settings.runScript 552 + state.scripts = settings.scripts 555 553 return .none 556 554 557 555 case .deeplinkReceived(let url, let source, let responseFD): ··· 748 746 case .commandPalette(.delegate(.openFailingCheckDetails(let worktreeID))): 749 747 return .send(.repositories(.pullRequestAction(worktreeID, .openFailingCheckDetails))) 750 748 749 + case .commandPalette(.delegate(.runScript(let definition))): 750 + return .send(.runNamedScript(definition)) 751 + 752 + case .commandPalette(.delegate(.stopScript(let scriptID, _))): 753 + // If a script was removed from settings while still running, 754 + // it won't appear here. That is intentional — the terminal 755 + // tab stays open and cleans up on natural completion or when 756 + // the user closes the tab manually. 757 + guard let definition = state.scripts.first(where: { $0.id == scriptID }) else { 758 + return .none 759 + } 760 + return .send(.stopScript(definition)) 761 + 751 762 #if DEBUG 752 763 case .commandPalette(.delegate(.debugTestToast(let toast))): 753 764 return .send(.repositories(.showToast(toast))) ··· 797 808 798 809 case .terminalEvent(.blockingScriptCompleted(let worktreeID, let kind, let exitCode, let tabId)): 799 810 switch kind { 800 - case .run: 801 - return .send(.repositories(.runScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) 811 + case .script(let definition): 812 + return .send( 813 + .repositories( 814 + .scriptCompleted( 815 + worktreeID: worktreeID, 816 + scriptID: definition.id, 817 + kind: kind, 818 + exitCode: exitCode, 819 + tabId: tabId 820 + ) 821 + ) 822 + ) 802 823 case .archive: 803 824 return .send(.repositories(.archiveScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) 804 825 case .delete: ··· 995 1016 case .run: 996 1017 return .send(.runScript) 997 1018 case .stop: 998 - return .send(.stopRunScript) 1019 + return .send(.stopRunScripts) 999 1020 case .archive: 1000 1021 guard let repositoryID = resolveRepositoryID(for: worktreeID, label: "archive", state: &state) else { 1001 1022 return .none
+11 -1
supacode/Features/CommandPalette/CommandPaletteItem.swift
··· 1 + import Foundation 1 2 import Sharing 2 3 import SupacodeSettingsShared 3 4 ··· 43 44 case copyCiFailureLogs(Worktree.ID) 44 45 case rerunFailedJobs(Worktree.ID) 45 46 case openFailingCheckDetails(Worktree.ID) 47 + case runScript(ScriptDefinition) 48 + case stopScript(UUID, name: String) 46 49 #if DEBUG 47 50 case debugTestToast(RepositoriesFeature.StatusToast) 48 51 #endif ··· 66 69 true 67 70 case .worktreeSelect, .removeWorktree, .archiveWorktree: 68 71 false 72 + case .runScript, .stopScript: 73 + true 69 74 #if DEBUG 70 75 case .debugTestToast: 71 76 true ··· 92 97 .removeWorktree, 93 98 .archiveWorktree: 94 99 false 100 + case .runScript, .stopScript: 101 + false 95 102 #if DEBUG 96 103 case .debugTestToast: 97 104 false ··· 118 125 .openFailingCheckDetails, 119 126 .worktreeSelect, 120 127 .removeWorktree, 121 - .archiveWorktree: 128 + .archiveWorktree, 129 + .stopScript: 122 130 nil 131 + case .runScript(let definition): 132 + definition.kind == .run ? AppShortcuts.runScript : nil 123 133 #if DEBUG 124 134 case .debugTestToast: 125 135 nil
+61 -4
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 1 1 import ComposableArchitecture 2 2 import Foundation 3 3 import Sharing 4 + import SupacodeSettingsShared 4 5 5 6 @Reducer 6 7 struct CommandPaletteFeature { ··· 49 50 case copyCiFailureLogs(Worktree.ID) 50 51 case rerunFailedJobs(Worktree.ID) 51 52 case openFailingCheckDetails(Worktree.ID) 53 + case runScript(ScriptDefinition) 54 + case stopScript(UUID, name: String) 52 55 #if DEBUG 53 56 case debugTestToast(RepositoriesFeature.StatusToast) 54 57 #endif ··· 163 166 164 167 static func commandPaletteItems( 165 168 from repositories: RepositoriesFeature.State, 166 - ghosttyCommands: [GhosttyCommand] = [] 169 + ghosttyCommands: [GhosttyCommand] = [], 170 + scripts: [ScriptDefinition] = [], 171 + runningScriptIDs: Set<UUID> = [] 167 172 ) -> [CommandPaletteItem] { 168 173 var items: [CommandPaletteItem] = [ 169 174 CommandPaletteItem( ··· 205 210 ] 206 211 if repositories.selectedWorktreeID != nil { 207 212 items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) 213 + items.append(contentsOf: scriptItems(scripts: scripts, runningScriptIDs: runningScriptIDs)) 208 214 } 209 215 if let selectedWorktreeID = repositories.selectedWorktreeID, 210 216 let repositoryID = repositories.repositoryID(containing: selectedWorktreeID), ··· 239 245 } 240 246 241 247 static func recencyRetentionIDs( 242 - from repositories: IdentifiedArrayOf<Repository> 248 + from repositories: IdentifiedArrayOf<Repository>, 249 + scripts: [ScriptDefinition] = [] 243 250 ) -> [CommandPaletteItem.ID] { 244 251 var ids = CommandPaletteItemID.globalIDs 245 252 for repository in repositories { ··· 247 254 for worktree in repository.worktrees { 248 255 ids.append(CommandPaletteItemID.worktreeSelect(worktree.id)) 249 256 } 257 + } 258 + for script in scripts { 259 + ids.append(CommandPaletteItemID.runScript(script.id)) 260 + ids.append(CommandPaletteItemID.stopScript(script.id)) 250 261 } 251 262 return ids 252 263 } ··· 333 344 subtitle: pullRequest.title, 334 345 kind: .openPullRequest(worktreeID), 335 346 priorityTier: 2 336 - ), 347 + ) 337 348 ] 338 349 339 350 if let readyItem = makeReadyItem() { ··· 491 502 static func pullRequestClose(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { 492 503 "pr.\(repositoryID).close" 493 504 } 505 + 506 + static func runScript(_ scriptID: UUID) -> CommandPaletteItem.ID { 507 + "script.\(scriptID).run" 508 + } 509 + 510 + static func stopScript(_ scriptID: UUID) -> CommandPaletteItem.ID { 511 + "script.\(scriptID).stop" 512 + } 494 513 } 495 514 496 515 private func prioritizeItems( ··· 556 575 .rerunFailedJobs, 557 576 .openFailingCheckDetails: 558 577 return pullRequestDelegateAction(for: kind)! 578 + case .runScript(let definition): 579 + return .runScript(definition) 580 + case .stopScript(let scriptID, let name): 581 + return .stopScript(scriptID, name: name) 559 582 #if DEBUG 560 583 case .debugTestToast(let toast): 561 584 return .debugTestToast(toast) ··· 592 615 .archiveWorktree, 593 616 .viewArchivedWorktrees, 594 617 .refreshWorktrees, 595 - .ghosttyCommand: 618 + .ghosttyCommand, 619 + .runScript, 620 + .stopScript: 596 621 return nil 597 622 #if DEBUG 598 623 case .debugTestToast: 599 624 return nil 600 625 #endif 601 626 } 627 + } 628 + 629 + private func scriptItems( 630 + scripts: [ScriptDefinition], 631 + runningScriptIDs: Set<UUID> 632 + ) -> [CommandPaletteItem] { 633 + var items: [CommandPaletteItem] = [] 634 + for script in scripts { 635 + let trimmed = script.command.trimmingCharacters(in: .whitespacesAndNewlines) 636 + guard !trimmed.isEmpty else { continue } 637 + if runningScriptIDs.contains(script.id) { 638 + items.append( 639 + CommandPaletteItem( 640 + id: CommandPaletteItemID.stopScript(script.id), 641 + title: "Stop: \(script.displayName)", 642 + subtitle: nil, 643 + kind: .stopScript(script.id, name: script.displayName), 644 + priorityTier: 0 645 + ) 646 + ) 647 + } else { 648 + items.append( 649 + CommandPaletteItem( 650 + id: CommandPaletteItemID.runScript(script.id), 651 + title: "Run: \(script.displayName)", 652 + subtitle: nil, 653 + kind: .runScript(script) 654 + ) 655 + ) 656 + } 657 + } 658 + return items 602 659 } 603 660 604 661 private func ghosttyCommandItems(_ commands: [GhosttyCommand]) -> [CommandPaletteItem] {
+15
supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift
··· 1 1 import AppKit 2 2 import ComposableArchitecture 3 3 import Foundation 4 + import SupacodeSettingsShared 4 5 import SwiftUI 5 6 6 7 struct CommandPaletteOverlayView: View { ··· 351 352 return "Remove" 352 353 case .archiveWorktree: 353 354 return "Archive" 355 + case .runScript: 356 + return "Script" 357 + case .stopScript: 358 + return "Script" 354 359 #if DEBUG 355 360 case .debugTestToast: 356 361 return "Debug" ··· 396 401 return "trash" 397 402 case .archiveWorktree: 398 403 return "archivebox" 404 + case .runScript(let definition): 405 + return definition.resolvedSystemImage 406 + case .stopScript: 407 + return "stop.fill" 399 408 #if DEBUG 400 409 case .debugTestToast: 401 410 return "ladybug" ··· 414 423 return true 415 424 case .worktreeSelect, .removeWorktree, .archiveWorktree: 416 425 return false 426 + case .runScript, .stopScript: 427 + return true 417 428 #if DEBUG 418 429 case .debugTestToast: 419 430 return true ··· 524 535 base = "Re-run failed jobs" 525 536 case .openFailingCheckDetails: 526 537 base = "Open failing check details" 538 + case .runScript(let definition): 539 + base = "Run \(definition.name)" 540 + case .stopScript(_, let name): 541 + base = "Stop \(name)" 527 542 #if DEBUG 528 543 case .debugTestToast: 529 544 base = row.title
+37 -11
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 77 77 var pendingWorktrees: [PendingWorktree] = [] 78 78 var pendingSetupScriptWorktreeIDs: Set<Worktree.ID> = [] 79 79 var pendingTerminalFocusWorktreeIDs: Set<Worktree.ID> = [] 80 - var runScriptWorktreeIDs: Set<Worktree.ID> = [] 80 + var runningScriptsByWorktreeID: [Worktree.ID: Set<UUID>] = [:] 81 81 var archivingWorktreeIDs: Set<Worktree.ID> = [] 82 82 var deleteScriptWorktreeIDs: Set<Worktree.ID> = [] 83 83 var deletingWorktreeIDs: Set<Worktree.ID> = [] ··· 200 200 ) 201 201 case consumeSetupScript(Worktree.ID) 202 202 case consumeTerminalFocus(Worktree.ID) 203 - case runScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?, tabId: TerminalTabID?) 203 + case scriptCompleted( 204 + worktreeID: Worktree.ID, scriptID: UUID, kind: BlockingScriptKind, exitCode: Int?, tabId: TerminalTabID?) 204 205 case requestArchiveWorktree(Worktree.ID, Repository.ID) 205 206 case requestArchiveWorktrees([ArchiveWorktreeTarget]) 206 207 case archiveWorktreeConfirmed(Worktree.ID, Repository.ID) ··· 1393 1394 } 1394 1395 ) 1395 1396 1396 - case .runScriptCompleted(let worktreeID, let exitCode, let tabId): 1397 - guard state.runScriptWorktreeIDs.contains(worktreeID) else { 1398 - repositoriesLogger.debug("Ignoring runScriptCompleted for \(worktreeID): not in runScriptWorktreeIDs") 1397 + case .scriptCompleted(let worktreeID, let scriptID, let kind, let exitCode, let tabId): 1398 + guard var ids = state.runningScriptsByWorktreeID[worktreeID], ids.contains(scriptID) else { 1399 + repositoriesLogger.debug("Ignoring scriptCompleted for \(worktreeID)/\(scriptID): not tracked") 1399 1400 return .none 1400 1401 } 1401 - state.runScriptWorktreeIDs.remove(worktreeID) 1402 + ids.remove(scriptID) 1403 + if ids.isEmpty { 1404 + state.runningScriptsByWorktreeID.removeValue(forKey: worktreeID) 1405 + } else { 1406 + state.runningScriptsByWorktreeID[worktreeID] = ids 1407 + } 1402 1408 guard let exitCode, exitCode != 0 else { return .none } 1403 1409 state.alert = blockingScriptFailureAlert( 1404 - kind: .run, exitCode: exitCode, worktreeID: worktreeID, tabId: tabId, state: state 1410 + kind: kind, 1411 + exitCode: exitCode, 1412 + worktreeID: worktreeID, 1413 + tabId: tabId, 1414 + state: state 1405 1415 ) 1406 1416 return .none 1407 1417 ··· 2010 2020 var effects: [Effect<Action>] = [ 2011 2021 .run { _ in 2012 2022 await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 2013 - }, 2023 + } 2014 2024 ] 2015 2025 if didUpdateWorktreeOrder { 2016 2026 let worktreeOrderByRepository = state.worktreeOrderByRepository ··· 2037 2047 var effects: [Effect<Action>] = [ 2038 2048 .run { _ in 2039 2049 await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 2040 - }, 2050 + } 2041 2051 ] 2042 2052 if didUpdateWorktreeOrder { 2043 2053 let worktreeOrderByRepository = state.worktreeOrderByRepository ··· 2912 2922 let filteredFocusIDs = state.pendingTerminalFocusWorktreeIDs.filter { 2913 2923 availableWorktreeIDs.contains($0) 2914 2924 } 2915 - let filteredRunScriptIDs = state.runScriptWorktreeIDs 2925 + let filteredRunningScripts = state.runningScriptsByWorktreeID.filter { 2926 + availableWorktreeIDs.contains($0.key) 2927 + } 2916 2928 let filteredArchivingIDs = state.archivingWorktreeIDs 2917 2929 let filteredWorktreeInfo = state.worktreeInfoByID.filter { 2918 2930 availableWorktreeIDs.contains($0.key) ··· 2926 2938 state.deleteScriptWorktreeIDs = filteredDeleteScriptIDs 2927 2939 state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs 2928 2940 state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs 2929 - state.runScriptWorktreeIDs = filteredRunScriptIDs 2941 + state.runningScriptsByWorktreeID = filteredRunningScripts 2942 + 2930 2943 state.archivingWorktreeIDs = filteredArchivingIDs 2931 2944 state.worktreeInfoByID = filteredWorktreeInfo 2932 2945 } ··· 2937 2950 state.deleteScriptWorktreeIDs = filteredDeleteScriptIDs 2938 2951 state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs 2939 2952 state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs 2953 + state.runningScriptsByWorktreeID = filteredRunningScripts 2940 2954 state.archivingWorktreeIDs = filteredArchivingIDs 2941 2955 state.worktreeInfoByID = filteredWorktreeInfo 2942 2956 } ··· 3144 3158 } 3145 3159 } 3146 3160 return nil 3161 + } 3162 + 3163 + /// Tint colors for scripts currently running in the given worktree, 3164 + /// ordered deterministically by script ID. Falls back to `.green` 3165 + /// for script IDs not found in the lookup (e.g. when the selected 3166 + /// worktree belongs to a different repository). 3167 + func runningScriptColors( 3168 + for worktreeID: Worktree.ID, 3169 + scriptsByID: [UUID: ScriptDefinition] 3170 + ) -> [TerminalTabTintColor] { 3171 + guard let scriptIDs = runningScriptsByWorktreeID[worktreeID] else { return [] } 3172 + return scriptIDs.sorted().map { scriptsByID[$0]?.resolvedTintColor ?? .green } 3147 3173 } 3148 3174 3149 3175 func pendingWorktree(for id: Worktree.ID?) -> PendingWorktree? {
+8 -14
supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift
··· 20 20 } 21 21 22 22 var body: some View { 23 - HStack(spacing: 6) { 23 + Label { 24 + if let shortcutHint { 25 + Text(shortcutHint) 26 + } else { 27 + Text(action.labelTitle) 28 + } 29 + } icon: { 24 30 if let icon = action.menuIcon { 25 31 switch icon { 26 32 case .app(let image): ··· 33 39 .accessibilityHidden(true) 34 40 } 35 41 } 36 - if let shortcutHint { 37 - HStack(spacing: 2) { 38 - Text(action.labelTitle) 39 - .font(.body) 40 - Text("(\(shortcutHint))") 41 - .font(.body) 42 - .foregroundStyle(.secondary) 43 - } 44 - } else { 45 - Text(action.labelTitle) 46 - .font(.body) 47 - } 48 - } 42 + }.labelStyle(.titleAndIcon) 49 43 } 50 44 }
+159 -127
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 1 1 import AppKit 2 2 import ComposableArchitecture 3 3 import Sharing 4 + import SupacodeSettingsFeature 4 5 import SupacodeSettingsShared 5 6 import SwiftUI 6 7 ··· 38 39 && loadingInfo == nil 39 40 && !showsMultiSelectionSummary 40 41 let openActionSelection = state.openActionSelection 41 - let runScriptEnabled = hasActiveWorktree 42 - let runScriptIsRunning = selectedWorktree.map { state.repositories.runScriptWorktreeIDs.contains($0.id) } == true 42 + let scripts = state.scripts 43 + let runningScriptIDs = state.runningScriptIDs 43 44 let notificationGroups = repositories.toolbarNotificationGroups(terminalManager: terminalManager) 44 45 let unseenNotificationWorktreeCount = notificationGroups.reduce(0) { count, repository in 45 46 count + repository.unseenWorktreeCount ··· 70 71 unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, 71 72 openActionSelection: openActionSelection, 72 73 showExtras: commandKeyObserver.isPressed, 73 - runScriptEnabled: runScriptEnabled, 74 - runScriptIsRunning: runScriptIsRunning 74 + scripts: scripts, 75 + runningScriptIDs: runningScriptIDs, 75 76 ) 76 77 WorktreeToolbarContent( 77 78 toolbarState: toolbarState, ··· 90 91 onSelectNotification: selectToolbarNotification, 91 92 onDismissAllNotifications: { dismissAllToolbarNotifications(in: notificationGroups) }, 92 93 onRunScript: { store.send(.runScript) }, 93 - onStopRunScript: { store.send(.stopRunScript) } 94 + onRunNamedScript: { store.send(.runNamedScript($0)) }, 95 + onStopScript: { store.send(.stopScript($0)) }, 96 + onStopRunScripts: { store.send(.stopRunScripts) }, 97 + onManageScripts: { 98 + let repositoryID = selectedWorktree.repositoryRootURL.path(percentEncoded: false) 99 + store.send(.settings(.setSelection(.repositoryScripts(repositoryID)))) 100 + } 94 101 ) 95 102 } 96 103 } 104 + let hasRunningRunScript = state.hasRunningRunScript 97 105 let actions = makeFocusedActions( 98 106 hasActiveWorktree: hasActiveWorktree, 99 - runScriptEnabled: runScriptEnabled, 100 - runScriptIsRunning: runScriptIsRunning 107 + hasRunningRunScript: hasRunningRunScript 101 108 ) 102 109 return applyFocusedActions(content: content, actions: actions) 103 110 } ··· 226 233 227 234 private func makeFocusedActions( 228 235 hasActiveWorktree: Bool, 229 - runScriptEnabled: Bool, 230 - runScriptIsRunning: Bool 236 + hasRunningRunScript: Bool 231 237 ) -> FocusedActions { 232 238 func action(_ appAction: AppFeature.Action) -> (() -> Void)? { 233 239 hasActiveWorktree ? { store.send(appAction) } : nil ··· 243 249 navigateSearchNext: action(.navigateSearchNext), 244 250 navigateSearchPrevious: action(.navigateSearchPrevious), 245 251 endSearch: action(.endSearch), 246 - runScript: runScriptEnabled ? { store.send(.runScript) } : nil, 247 - stopRunScript: runScriptIsRunning ? { store.send(.stopRunScript) } : nil 252 + runScript: hasActiveWorktree ? { store.send(.runScript) } : nil, 253 + stopRunScript: hasRunningRunScript ? { store.send(.stopRunScripts) } : nil, 248 254 ) 249 255 } 250 256 ··· 289 295 let unseenNotificationWorktreeCount: Int 290 296 let openActionSelection: OpenWorktreeAction 291 297 let showExtras: Bool 292 - let runScriptEnabled: Bool 293 - let runScriptIsRunning: Bool 298 + let scripts: [ScriptDefinition] 299 + let runningScriptIDs: Set<UUID> 300 + 301 + /// The first `.run`-kind script, if any. 302 + var primaryScript: ScriptDefinition? { 303 + scripts.primaryScript 304 + } 305 + 306 + /// Whether any `.run`-kind script is currently running. 307 + var hasRunningRunScript: Bool { 308 + scripts.hasRunningRunScript(in: runningScriptIDs) 309 + } 294 310 295 311 var runScriptHelpText: String { 296 312 @Shared(.settingsFile) var settingsFile ··· 314 330 let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void 315 331 let onDismissAllNotifications: () -> Void 316 332 let onRunScript: () -> Void 317 - let onStopRunScript: () -> Void 333 + let onRunNamedScript: (ScriptDefinition) -> Void 334 + let onStopScript: (ScriptDefinition) -> Void 335 + let onStopRunScripts: () -> Void 336 + let onManageScripts: () -> Void 318 337 319 338 var body: some ToolbarContent { 320 339 ToolbarItem { ··· 348 367 349 368 ToolbarSpacer(.flexible) 350 369 351 - ToolbarItemGroup { 370 + ToolbarItem { 352 371 openMenu( 353 372 openActionSelection: toolbarState.openActionSelection, 354 373 showExtras: toolbarState.showExtras ··· 356 375 } 357 376 ToolbarSpacer(.fixed) 358 377 359 - if toolbarState.runScriptIsRunning || toolbarState.runScriptEnabled { 360 - ToolbarItem { 361 - RunScriptToolbarButton( 362 - isRunning: toolbarState.runScriptIsRunning, 363 - isEnabled: toolbarState.runScriptEnabled, 364 - runHelpText: toolbarState.runScriptHelpText, 365 - stopHelpText: toolbarState.stopRunScriptHelpText, 366 - runShortcut: shortcutDisplay(for: AppShortcuts.runScript, fallback: ""), 367 - stopShortcut: shortcutDisplay(for: AppShortcuts.stopRunScript, fallback: ""), 368 - runAction: onRunScript, 369 - stopAction: onStopRunScript 370 - ) 371 - } 378 + ToolbarItem { 379 + ScriptMenu( 380 + toolbarState: toolbarState, 381 + onRunScript: onRunScript, 382 + onRunNamedScript: onRunNamedScript, 383 + onStopScript: onStopScript, 384 + onStopRunScripts: onStopRunScripts, 385 + onManageScripts: onManageScripts 386 + ) 372 387 } 373 388 374 389 } ··· 379 394 let resolved = OpenWorktreeAction.availableSelection(openActionSelection) 380 395 let primarySelection = resolved == .finder ? availableActions.first : resolved 381 396 if let primarySelection { 382 - Button { 383 - onOpenWorktree(primarySelection) 397 + Menu { 398 + ForEach(availableActions) { action in 399 + let isDefault = action == primarySelection 400 + Button { 401 + onOpenActionSelectionChanged(action) 402 + onOpenWorktree(action) 403 + } label: { 404 + OpenWorktreeActionMenuLabelView(action: action, shortcutHint: nil) 405 + } 406 + .buttonStyle(.plain) 407 + .help(openActionHelpText(for: action, isDefault: isDefault)) 408 + } 409 + Divider() 410 + Button { 411 + onRevealInFinder() 412 + } label: { 413 + OpenWorktreeActionMenuLabelView(action: .finder, shortcutHint: nil) 414 + } 415 + .help("Reveal in Finder (\(resolveShortcutDisplay(for: AppShortcuts.revealInFinder)))") 384 416 } label: { 385 417 OpenWorktreeActionMenuLabelView( 386 418 action: primarySelection, 387 - shortcutHint: showExtras ? shortcutDisplay(for: AppShortcuts.openWorktree, fallback: "") : nil 419 + shortcutHint: showExtras ? resolveShortcutDisplay(for: AppShortcuts.openWorktree, fallback: "") : nil 388 420 ) 421 + } primaryAction: { 422 + onOpenWorktree(primarySelection) 389 423 } 390 424 .help(openActionHelpText(for: primarySelection, isDefault: true)) 391 425 } 392 - 393 - Menu { 394 - ForEach(availableActions) { action in 395 - let isDefault = action == primarySelection 396 - Button { 397 - onOpenActionSelectionChanged(action) 398 - onOpenWorktree(action) 399 - } label: { 400 - OpenWorktreeActionMenuLabelView(action: action, shortcutHint: nil) 401 - } 402 - .buttonStyle(.plain) 403 - .help(openActionHelpText(for: action, isDefault: isDefault)) 404 - } 405 - Divider() 406 - Button { 407 - onRevealInFinder() 408 - } label: { 409 - OpenWorktreeActionMenuLabelView(action: .finder, shortcutHint: nil) 410 - } 411 - .help("Reveal in Finder (\(shortcutDisplay(for: AppShortcuts.revealInFinder)))") 412 - } label: { 413 - Image(systemName: "chevron.down") 414 - .font(.caption2) 415 - .accessibilityLabel("Open in menu") 416 - } 417 - .imageScale(.small) 418 - .menuIndicator(.hidden) 419 - .fixedSize() 420 - .help("Open in…") 421 - } 422 - 423 - private func shortcutDisplay(for shortcut: AppShortcut, fallback: String = "none") -> String { 424 - @Shared(.settingsFile) var settingsFile 425 - return shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback 426 426 } 427 427 428 428 private func openActionHelpText(for action: OpenWorktreeAction, isDefault: Bool) -> String { 429 429 guard isDefault else { return action.title } 430 - return "\(action.title) (\(shortcutDisplay(for: AppShortcuts.openWorktree)))" 430 + return "\(action.title) (\(resolveShortcutDisplay(for: AppShortcuts.openWorktree)))" 431 431 } 432 432 } 433 433 ··· 590 590 ToolbarItem { 591 591 Button { 592 592 } label: { 593 - HStack(spacing: 6) { 594 - Image(systemName: "play.fill") 593 + Label { 595 594 Text("Run") 595 + } icon: { 596 + Image(systemName: "play") 596 597 } 598 + .labelStyle(.titleAndIcon) 597 599 } 598 - .font(.caption) 599 600 .redacted(reason: .placeholder) 600 601 .shimmer(isActive: true) 601 602 } ··· 608 609 let repositoryName: String? 609 610 } 610 611 612 + /// Resolves a shortcut's display string from the user's settings. 613 + private func resolveShortcutDisplay(for shortcut: AppShortcut, fallback: String = "none") -> String { 614 + @Shared(.settingsFile) var settingsFile 615 + let display = shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback 616 + return display.isEmpty ? fallback : display 617 + } 618 + 611 619 private struct MultiSelectedWorktreesDetailView: View { 612 620 let rows: [MultiSelectedWorktreeSummary] 613 621 ··· 655 663 } 656 664 } 657 665 658 - private struct RunScriptToolbarButton: View { 659 - let isRunning: Bool 660 - let isEnabled: Bool 661 - let runHelpText: String 662 - let stopHelpText: String 663 - let runShortcut: String 664 - let stopShortcut: String 665 - let runAction: () -> Void 666 - let stopAction: () -> Void 666 + /// Menu with primary action for running scripts in the toolbar. 667 + /// Click runs the default script, stops running scripts, or opens settings; 668 + /// long-press/arrow opens the full script list. 669 + private struct ScriptMenu: View { 670 + let toolbarState: WorktreeDetailView.WorktreeToolbarState 671 + let onRunScript: () -> Void 672 + let onRunNamedScript: (ScriptDefinition) -> Void 673 + let onStopScript: (ScriptDefinition) -> Void 674 + let onStopRunScripts: () -> Void 675 + let onManageScripts: () -> Void 667 676 @Environment(CommandKeyObserver.self) private var commandKeyObserver 668 677 678 + private var primaryScript: ScriptDefinition? { 679 + toolbarState.primaryScript 680 + } 681 + 669 682 var body: some View { 670 - if isRunning { 671 - button( 672 - config: RunScriptButtonConfig( 673 - title: "Stop", 674 - systemImage: "stop.fill", 675 - helpText: stopHelpText, 676 - shortcut: stopShortcut, 677 - isEnabled: true, 678 - action: stopAction 679 - )) 680 - } else { 681 - button( 682 - config: RunScriptButtonConfig( 683 - title: "Run", 684 - systemImage: "play.fill", 685 - helpText: runHelpText, 686 - shortcut: runShortcut, 687 - isEnabled: isEnabled, 688 - action: runAction 689 - )) 683 + let hasRunning = toolbarState.hasRunningRunScript 684 + Menu { 685 + ForEach(toolbarState.scripts) { script in 686 + let isRunning = toolbarState.runningScriptIDs.contains(script.id) 687 + Button { 688 + if isRunning { 689 + onStopScript(script) 690 + } else { 691 + onRunNamedScript(script) 692 + } 693 + } label: { 694 + Label { 695 + Text(isRunning ? "Stop \(script.displayName)" : script.displayName) 696 + } icon: { 697 + Image.tintedSymbol( 698 + isRunning ? "stop" : script.resolvedSystemImage, 699 + color: script.resolvedTintColor.nsColor, 700 + ) 701 + } 702 + } 703 + .help(isRunning ? "Stop \(script.displayName)." : "Run \(script.displayName).") 704 + } 705 + if !toolbarState.scripts.isEmpty { 706 + Divider() 707 + } 708 + Button("Manage Scripts…") { 709 + onManageScripts() 710 + } 711 + .help("Open repository settings to manage scripts.") 712 + } label: { 713 + scriptLabel(hasRunning: hasRunning) 714 + } primaryAction: { 715 + if hasRunning { 716 + onStopRunScripts() 717 + } else if primaryScript != nil { 718 + onRunScript() 719 + } else { 720 + onManageScripts() 721 + } 690 722 } 723 + .help(primaryHelpText(hasRunning: hasRunning)) 691 724 } 692 725 693 726 @ViewBuilder 694 - private func button(config: RunScriptButtonConfig) -> some View { 695 - Button { 696 - config.action() 697 - } label: { 698 - HStack(spacing: 6) { 699 - Image(systemName: config.systemImage) 700 - .accessibilityHidden(true) 701 - Text(config.title) 727 + private func scriptLabel(hasRunning: Bool) -> some View { 728 + let icon = hasRunning ? "stop" : (primaryScript?.resolvedSystemImage ?? "play") 729 + let label = hasRunning ? "Stop" : (primaryScript?.displayName ?? "Run") 730 + let shortcut = hasRunning ? AppShortcuts.stopRunScript : AppShortcuts.runScript 731 + Label { 732 + Text( 733 + commandKeyObserver.isPressed 734 + ? resolveShortcutDisplay(for: shortcut, fallback: label) 735 + : label 736 + ) 737 + } icon: { 738 + Image(systemName: icon) 739 + .accessibilityHidden(true) 740 + }.labelStyle(.titleAndIcon) 741 + } 702 742 703 - if commandKeyObserver.isPressed { 704 - Text(config.shortcut) 705 - .font(.caption) 706 - .foregroundStyle(.secondary) 707 - } 708 - } 743 + private func primaryHelpText(hasRunning: Bool) -> String { 744 + if hasRunning { 745 + return toolbarState.stopRunScriptHelpText 709 746 } 710 - .font(.caption) 711 - .help(config.helpText) 712 - .disabled(!config.isEnabled) 713 - } 714 - 715 - private struct RunScriptButtonConfig { 716 - let title: String 717 - let systemImage: String 718 - let helpText: String 719 - let shortcut: String 720 - let isEnabled: Bool 721 - let action: () -> Void 747 + guard primaryScript != nil else { 748 + return "Configure scripts in Settings." 749 + } 750 + return toolbarState.runScriptHelpText 722 751 } 723 752 } 724 753 ··· 736 765 unseenNotificationWorktreeCount: 0, 737 766 openActionSelection: .finder, 738 767 showExtras: false, 739 - runScriptEnabled: true, 740 - runScriptIsRunning: false 768 + scripts: [ScriptDefinition(kind: .run, command: "npm run dev")], 769 + runningScriptIDs: [], 741 770 ) 742 771 let observer = CommandKeyObserver() 743 772 observer.isPressed = false ··· 759 788 onSelectNotification: { _, _ in }, 760 789 onDismissAllNotifications: {}, 761 790 onRunScript: {}, 762 - onStopRunScript: {} 791 + onRunNamedScript: { _ in }, 792 + onStopScript: { _ in }, 793 + onStopRunScripts: {}, 794 + onManageScripts: {} 763 795 ) 764 796 } 765 797 .environment(commandKeyObserver)
+103 -19
supacode/Features/Repositories/Views/WorktreeRow.swift
··· 14 14 let info: WorktreeInfoEntry? 15 15 let pullRequestBadgeText: String? 16 16 let showsPullRequestInfo: Bool 17 - let isRunScriptRunning: Bool 17 + let runningScriptColors: [TerminalTabTintColor] 18 18 let showsNotificationIndicator: Bool 19 19 let notifications: [WorktreeTerminalNotification] 20 20 let shortcutHint: String? ··· 62 62 hideSubtitle: Bool, 63 63 hideSubtitleOnMatch: Bool, 64 64 showsPullRequestInfo: Bool, 65 - isRunScriptRunning: Bool, 65 + runningScriptColors: [TerminalTabTintColor], 66 66 isTaskRunning: Bool, 67 67 showsNotificationIndicator: Bool, 68 68 notifications: [WorktreeTerminalNotification], ··· 73 73 self.isPending = row.isPending 74 74 self.info = row.info 75 75 self.showsPullRequestInfo = showsPullRequestInfo 76 - self.isRunScriptRunning = isRunScriptRunning 76 + self.runningScriptColors = runningScriptColors 77 77 self.showsNotificationIndicator = showsNotificationIndicator 78 78 self.notifications = notifications 79 79 self.shortcutHint = shortcutHint ··· 178 178 info: info, 179 179 showsPullRequestInfo: showsPullRequestInfo, 180 180 pullRequestBadgeText: pullRequestBadgeText, 181 - isRunScriptRunning: isRunScriptRunning, 181 + runningScriptColors: runningScriptColors, 182 182 showsNotificationIndicator: showsNotificationIndicator, 183 183 notifications: notifications 184 184 ) ··· 315 315 let info: WorktreeInfoEntry? 316 316 let showsPullRequestInfo: Bool 317 317 let pullRequestBadgeText: String? 318 - let isRunScriptRunning: Bool 318 + let runningScriptColors: [TerminalTabTintColor] 319 319 let showsNotificationIndicator: Bool 320 320 let notifications: [WorktreeTerminalNotification] 321 321 ··· 334 334 .transition(.blurReplace) 335 335 } 336 336 StatusIndicator( 337 - isRunScriptRunning: isRunScriptRunning, 337 + runningScriptColors: runningScriptColors, 338 338 showsNotificationIndicator: showsNotificationIndicator, 339 339 notifications: notifications 340 340 ) ··· 368 368 // MARK: - Status indicator. 369 369 370 370 private struct StatusIndicator: View { 371 - let isRunScriptRunning: Bool 371 + let runningScriptColors: [TerminalTabTintColor] 372 372 let showsNotificationIndicator: Bool 373 373 let notifications: [WorktreeTerminalNotification] 374 374 @Environment(\.backgroundProminence) private var backgroundProminence ··· 376 376 377 377 var body: some View { 378 378 let isEmphasized = backgroundProminence == .increased 379 - if isRunScriptRunning || showsNotificationIndicator { 379 + let isRunning = !runningScriptColors.isEmpty 380 + if isRunning || showsNotificationIndicator { 380 381 ZStack { 381 - if isRunScriptRunning { 382 - PingDot( 383 - style: isEmphasized ? AnyShapeStyle(.primary) : AnyShapeStyle(.green), 382 + if isRunning { 383 + MultiColorPingDot( 384 + colors: runningScriptColors, 385 + isEmphasized: isEmphasized, 384 386 size: 6, 385 387 showsSolidCenter: !showsNotificationIndicator 386 388 ) ··· 400 402 } 401 403 } 402 404 403 - // MARK: - Vertically centered label style. 405 + // MARK: - Multi-color ping dot. 406 + 407 + /// Displays a pulsing dot that cycles through multiple script tint 408 + /// colors when more than one script is running. Falls back to the 409 + /// single-color pulsing behavior when only one color is present. 410 + private struct MultiColorPingDot: View { 411 + let colors: [TerminalTabTintColor] 412 + let isEmphasized: Bool 413 + let size: CGFloat 414 + let showsSolidCenter: Bool 415 + @Environment(\.accessibilityReduceMotion) private var reduceMotion 404 416 405 - private struct VerticallyCenteredLabelStyle: LabelStyle { 406 - func makeBody(configuration: Configuration) -> some View { 407 - HStack(spacing: 6) { 408 - configuration.icon 409 - configuration.title 417 + /// Unique, ordered colors derived from the input. 418 + private var uniqueColors: [Color] { 419 + guard !isEmphasized else { return [.primary] } 420 + var seen = Set<TerminalTabTintColor>() 421 + return colors.compactMap { tint in 422 + guard seen.insert(tint).inserted else { return nil } 423 + return tint.color 424 + } 425 + } 426 + 427 + var body: some View { 428 + let resolved = uniqueColors 429 + if resolved.count <= 1 { 430 + PingDot( 431 + style: resolved.first.map { AnyShapeStyle($0) } ?? AnyShapeStyle(.green), 432 + size: size, 433 + showsSolidCenter: showsSolidCenter 434 + ) 435 + } else if reduceMotion { 436 + // Show a static dot with the first color when motion is reduced. 437 + StaticDot(color: resolved[0], size: size, showsSolidCenter: showsSolidCenter) 438 + } else { 439 + CyclingDot(colors: resolved, size: size, showsSolidCenter: showsSolidCenter) 410 440 } 411 441 } 412 442 } 413 443 414 - extension LabelStyle where Self == VerticallyCenteredLabelStyle { 415 - static var verticallyCentered: VerticallyCenteredLabelStyle { .init() } 444 + /// Static dot used when accessibility reduce-motion is enabled. 445 + private struct StaticDot: View { 446 + let color: Color 447 + let size: CGFloat 448 + let showsSolidCenter: Bool 449 + 450 + var body: some View { 451 + ZStack { 452 + Circle() 453 + .stroke(color, lineWidth: 1) 454 + .frame(width: size, height: size) 455 + .opacity(0.6) 456 + if showsSolidCenter { 457 + Circle() 458 + .fill(color) 459 + .frame(width: size, height: size) 460 + } 461 + } 462 + .accessibilityLabel("Run script active") 463 + } 464 + } 465 + 466 + /// Animated dot that smoothly cycles through the provided colors. 467 + private struct CyclingDot: View { 468 + let colors: [Color] 469 + let size: CGFloat 470 + let showsSolidCenter: Bool 471 + @State private var isPinging = false 472 + 473 + var body: some View { 474 + TimelineView(.periodic(from: .now, by: 2.0)) { timeline in 475 + let index = Self.colorIndex(for: timeline.date, count: colors.count) 476 + ZStack { 477 + Circle() 478 + .stroke(colors[index], lineWidth: 1) 479 + .frame(width: size, height: size) 480 + .scaleEffect(isPinging ? 2 : 1) 481 + .opacity(isPinging ? 0 : 0.6) 482 + .animation(.easeOut(duration: 1).repeatForever(autoreverses: false), value: isPinging) 483 + if showsSolidCenter { 484 + Circle() 485 + .fill(colors[index]) 486 + .frame(width: size, height: size) 487 + } 488 + } 489 + .animation(.easeInOut(duration: 0.6), value: index) 490 + } 491 + .accessibilityLabel("Run script active") 492 + .task { isPinging = true } 493 + } 494 + 495 + private static func colorIndex(for date: Date, count: Int) -> Int { 496 + guard count > 0 else { return 0 } 497 + let seconds = Int(date.timeIntervalSinceReferenceDate) 498 + return (seconds / 2) % count 499 + } 416 500 } 417 501 418 502 // MARK: - Pulsing dot.
+2 -1
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 153 153 let shortcutHint: String? 154 154 @Shared(.appStorage("worktreeRowDisplayMode")) private var displayMode: WorktreeRowDisplayMode = .branchFirst 155 155 @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true 156 + @Environment(\.scriptsByID) private var scriptsByID 156 157 157 158 var body: some View { 158 159 WorktreeRow( ··· 161 162 hideSubtitle: hideSubtitle, 162 163 hideSubtitleOnMatch: hideSubtitleOnMatch, 163 164 showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), 164 - isRunScriptRunning: store.state.runScriptWorktreeIDs.contains(row.id), 165 + runningScriptColors: store.state.runningScriptColors(for: row.id, scriptsByID: scriptsByID), 165 166 isTaskRunning: terminalManager.stateIfExists(for: row.id)?.taskStatus == .running, 166 167 showsNotificationIndicator: terminalManager.hasUnseenNotifications(for: row.id), 167 168 notifications: terminalManager.stateIfExists(for: row.id)?.notifications ?? [],
+151 -57
supacode/Features/Settings/Views/SettingsView.swift
··· 38 38 } 39 39 } 40 40 41 + /// Disclosure group label for a repository in the settings sidebar. 42 + private struct RepositoryDisclosureLabel: View { 43 + let repository: SettingsRepositorySummary 44 + @Bindable var settingsStore: StoreOf<SettingsFeature> 45 + @Binding var isExpanded: Bool 46 + 47 + private var isSelected: Bool { 48 + settingsStore.selection?.repositoryID == repository.id 49 + } 50 + 51 + var body: some View { 52 + RepositoryLabel(name: repository.name, rootURL: repository.rootURL) 53 + .contentShape(Rectangle()) 54 + .accessibilityAddTraits(.isButton) 55 + .onTapGesture { 56 + guard !isSelected else { 57 + isExpanded.toggle() 58 + return 59 + } 60 + _ = settingsStore.send(.setSelection(.repository(repository.id))) 61 + } 62 + } 63 + } 64 + 65 + /// Sidebar content for the settings split view. 66 + private struct SettingsSidebarView: View { 67 + @Bindable var settingsStore: StoreOf<SettingsFeature> 68 + @Binding var expandedRepositories: Set<String> 69 + 70 + var body: some View { 71 + List(selection: $settingsStore.selection.sending(\.setSelection)) { 72 + Label("General", systemImage: "gearshape") 73 + .tag(SettingsSection.general) 74 + Label("Notifications", systemImage: "bell") 75 + .tag(SettingsSection.notifications) 76 + Label("Worktrees", systemImage: "list.dash") 77 + .tag(SettingsSection.worktree) 78 + Label("Developer", systemImage: "hammer") 79 + .tag(SettingsSection.developer) 80 + Label("GitHub", image: "github-mark") 81 + .tag(SettingsSection.github) 82 + Label("Shortcuts", systemImage: "keyboard") 83 + .tag(SettingsSection.shortcuts) 84 + Label("Updates", systemImage: "arrow.down.circle") 85 + .tag(SettingsSection.updates) 86 + 87 + Section("Repositories") { 88 + ForEach(settingsStore.repositorySummaries, id: \.id) { repository in 89 + let isExpanded = Binding( 90 + get: { expandedRepositories.contains(repository.id) }, 91 + set: { expanded in 92 + if expanded { 93 + expandedRepositories.insert(repository.id) 94 + } else { 95 + expandedRepositories.remove(repository.id) 96 + } 97 + } 98 + ) 99 + DisclosureGroup(isExpanded: isExpanded) { 100 + Label("General", systemImage: "gearshape") 101 + .tag(SettingsSection.repository(repository.id)) 102 + Label("Scripts", systemImage: "terminal") 103 + .tag(SettingsSection.repositoryScripts(repository.id)) 104 + } label: { 105 + RepositoryDisclosureLabel( 106 + repository: repository, 107 + settingsStore: settingsStore, 108 + isExpanded: isExpanded 109 + ) 110 + } 111 + } 112 + } 113 + } 114 + .listStyle(.sidebar) 115 + .frame(minWidth: 220, maxHeight: .infinity) 116 + .navigationSplitViewColumnWidth(220) 117 + .toolbar(removing: .sidebarToggle) 118 + } 119 + } 120 + 121 + /// Detail pane content for the settings split view. 122 + private struct SettingsDetailView: View { 123 + let selection: SettingsSection 124 + let selectedRepositorySummary: SettingsRepositorySummary? 125 + @Bindable var settingsStore: StoreOf<SettingsFeature> 126 + let updatesStore: StoreOf<UpdatesFeature> 127 + 128 + var body: some View { 129 + switch selection { 130 + case .general: 131 + AppearanceSettingsView(store: settingsStore) 132 + case .notifications: 133 + NotificationsSettingsView(store: settingsStore) 134 + case .worktree: 135 + WorktreeSettingsView(store: settingsStore) 136 + case .developer: 137 + DeveloperSettingsView(store: settingsStore) 138 + case .shortcuts: 139 + KeyboardShortcutsSettingsView(store: settingsStore) 140 + case .updates: 141 + UpdatesSettingsView(settingsStore: settingsStore, updatesStore: updatesStore) 142 + case .github: 143 + GithubSettingsView(store: settingsStore) 144 + case .repository: 145 + if let repository = selectedRepositorySummary { 146 + IfLetStore(settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings)) { 147 + repositorySettingsStore in 148 + RepositorySettingsView(store: repositorySettingsStore) 149 + .id(repository.id) 150 + .navigationTitle(repository.name) 151 + } 152 + } else { 153 + Text("Repository not found.") 154 + .foregroundStyle(.secondary) 155 + .frame(maxWidth: .infinity, alignment: .leading) 156 + .navigationTitle("Repositories") 157 + } 158 + case .repositoryScripts: 159 + if let repository = selectedRepositorySummary { 160 + IfLetStore(settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings)) { 161 + repositorySettingsStore in 162 + RepositoryScriptsSettingsView(store: repositorySettingsStore) 163 + .id("\(repository.id)-scripts") 164 + .navigationTitle("\(repository.name) — Scripts") 165 + } 166 + } else { 167 + Text("Repository not found.") 168 + .foregroundStyle(.secondary) 169 + .frame(maxWidth: .infinity, alignment: .leading) 170 + .navigationTitle("Scripts") 171 + } 172 + } 173 + } 174 + } 175 + 41 176 struct SettingsView: View { 42 177 @Bindable var store: StoreOf<AppFeature> 43 178 @Bindable var settingsStore: StoreOf<SettingsFeature> 179 + @State private var expandedRepositories: Set<String> = [] 44 180 45 181 init(store: StoreOf<AppFeature>) { 46 182 self.store = store ··· 51 187 let updatesStore = store.scope(state: \.updates, action: \.updates) 52 188 let selection = settingsStore.selection ?? .general 53 189 let selectedRepositorySummary: SettingsRepositorySummary? = { 54 - guard case .repository(let repositoryID) = selection else { 190 + guard let repositoryID = selection.repositoryID else { 55 191 return nil 56 192 } 57 193 return settingsStore.repositorySummaries.first(where: { $0.id == repositoryID }) 58 194 }() 59 195 60 196 NavigationSplitView(columnVisibility: .constant(.all)) { 61 - List(selection: $settingsStore.selection.sending(\.setSelection)) { 62 - Label("General", systemImage: "gearshape") 63 - .tag(SettingsSection.general) 64 - Label("Notifications", systemImage: "bell") 65 - .tag(SettingsSection.notifications) 66 - Label("Worktrees", systemImage: "list.dash") 67 - .tag(SettingsSection.worktree) 68 - Label("Developer", systemImage: "hammer") 69 - .tag(SettingsSection.developer) 70 - Label("GitHub", image: "github-mark") 71 - .tag(SettingsSection.github) 72 - Label("Shortcuts", systemImage: "keyboard") 73 - .tag(SettingsSection.shortcuts) 74 - Label("Updates", systemImage: "arrow.down.circle") 75 - .tag(SettingsSection.updates) 76 - 77 - Section("Repositories") { 78 - ForEach(settingsStore.repositorySummaries, id: \.id) { repository in 79 - RepositoryLabel(name: repository.name, rootURL: repository.rootURL) 80 - .tag(SettingsSection.repository(repository.id)) 81 - } 82 - } 197 + SettingsSidebarView( 198 + settingsStore: settingsStore, 199 + expandedRepositories: $expandedRepositories 200 + ) 201 + .onChange(of: selection) { _, newSelection in 202 + // Auto-expand the repository disclosure group when navigating to it. 203 + guard let repositoryID = newSelection.repositoryID else { return } 204 + expandedRepositories.insert(repositoryID) 83 205 } 84 - .listStyle(.sidebar) 85 - .frame(minWidth: 220, maxHeight: .infinity) 86 - .navigationSplitViewColumnWidth(220) 87 - .toolbar(removing: .sidebarToggle) 88 206 } detail: { 89 - switch selection { 90 - case .general: 91 - AppearanceSettingsView(store: settingsStore) 92 - case .notifications: 93 - NotificationsSettingsView(store: settingsStore) 94 - case .worktree: 95 - WorktreeSettingsView(store: settingsStore) 96 - case .developer: 97 - DeveloperSettingsView(store: settingsStore) 98 - case .shortcuts: 99 - KeyboardShortcutsSettingsView(store: settingsStore) 100 - case .updates: 101 - UpdatesSettingsView(settingsStore: settingsStore, updatesStore: updatesStore) 102 - case .github: 103 - GithubSettingsView(store: settingsStore) 104 - case .repository: 105 - if let repository = selectedRepositorySummary { 106 - IfLetStore(settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings)) { 107 - repositorySettingsStore in 108 - RepositorySettingsView(store: repositorySettingsStore) 109 - .id(repository.id) 110 - .navigationTitle(repository.name) 111 - } 112 - } else { 113 - Text("Repository not found.") 114 - .foregroundStyle(.secondary) 115 - .frame(maxWidth: .infinity, alignment: .leading) 116 - .navigationTitle("Repositories") 117 - } 118 - } 207 + SettingsDetailView( 208 + selection: selection, 209 + selectedRepositorySummary: selectedRepositorySummary, 210 + settingsStore: settingsStore, 211 + updatesStore: updatesStore 212 + ) 119 213 } 120 214 .toolbar { 121 215 // Invisible item keeps the toolbar stable when switching between
+16 -14
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 122 122 let state = state(for: worktree) { runSetupScriptIfNew } 123 123 state.ensureInitialTab(focusing: focusing) 124 124 case .stopRunScript(let worktree): 125 - _ = state(for: worktree).stopRunScript() 125 + _ = state(for: worktree).stopRunScripts() 126 + case .stopScript(let worktree, let definitionID): 127 + _ = state(for: worktree).stopScript(definitionID: definitionID) 126 128 case .runBlockingScript(let worktree, let kind, let script): 127 129 _ = state(for: worktree).runBlockingScript(kind: kind, script) 128 130 case .closeFocusedTab(let worktree): ··· 187 189 state(for: worktree).navigateSearchOnFocusedSurface(.previous) 188 190 case .endSearch(let worktree): 189 191 state(for: worktree).performBindingActionOnFocusedSurface("end_search") 190 - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .runBlockingScript, 191 - .closeFocusedTab, .closeFocusedSurface, .performBindingAction, .selectTab, .focusSurface, 192 - .splitSurface, .destroyTab, .destroySurface, .prune, .setNotificationsEnabled, 193 - .setSelectedWorktreeID, .refreshTabBarVisibility: 192 + case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, 193 + .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, 194 + .selectTab, .focusSurface, .splitSurface, .destroyTab, .destroySurface, .prune, 195 + .setNotificationsEnabled, .setSelectedWorktreeID, .refreshTabBarVisibility: 194 196 return false 195 197 } 196 198 return true ··· 200 202 switch command { 201 203 case .performBindingAction(let worktree, let action): 202 204 state(for: worktree).performBindingActionOnFocusedSurface(action) 203 - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .runBlockingScript, 204 - .closeFocusedTab, .closeFocusedSurface, .startSearch, .searchSelection, .navigateSearchNext, 205 - .navigateSearchPrevious, .endSearch, .selectTab, .focusSurface, .splitSurface, .destroyTab, 206 - .destroySurface, .prune, .setNotificationsEnabled, .setSelectedWorktreeID, 207 - .refreshTabBarVisibility: 205 + case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, 206 + .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .startSearch, .searchSelection, 207 + .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .focusSurface, 208 + .splitSurface, .destroyTab, .destroySurface, .prune, .setNotificationsEnabled, 209 + .setSelectedWorktreeID, .refreshTabBarVisibility: 208 210 return false 209 211 } 210 212 return true ··· 228 230 } 229 231 selectedWorktreeID = id 230 232 terminalLogger.info("Selected worktree \(id ?? "nil")") 231 - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .runBlockingScript, 232 - .closeFocusedTab, .closeFocusedSurface, .performBindingAction, .startSearch, .searchSelection, 233 - .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .focusSurface, 234 - .splitSurface, .destroyTab, .destroySurface: 233 + case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, 234 + .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, 235 + .startSearch, .searchSelection, .navigateSearchNext, .navigateSearchPrevious, .endSearch, 236 + .selectTab, .focusSurface, .splitSurface, .destroyTab, .destroySurface: 235 237 assertionFailure("Unhandled terminal command reached management handler: \(command)") 236 238 } 237 239 }
+58 -8
supacode/Features/Terminal/Models/BlockingScriptKind.swift
··· 1 + import Foundation 2 + import SupacodeSettingsShared 3 + 1 4 /// Identifies the kind of script that runs in a dedicated terminal tab 2 - /// with exit-code tracking. Some kinds (archive, delete) block worktree 3 - /// state transitions until the script completes. Adding a new case 4 - /// requires handling in `AppFeature`'s `.blockingScriptCompleted` event router. 5 - enum BlockingScriptKind: Hashable, Sendable, CaseIterable { 6 - case run 5 + /// with exit-code tracking. `.archive` and `.delete` block worktree 6 + /// state transitions until the script completes. `.script` wraps a 7 + /// user-defined `ScriptDefinition` and can run concurrently. 8 + /// 9 + /// Equality and hashing for the `.script` case use only the 10 + /// definition's `id`, so dictionary lookups and dedup checks remain 11 + /// stable even when the user edits the script's name or command. 12 + enum BlockingScriptKind: Sendable { 13 + case script(ScriptDefinition) 7 14 case archive 8 15 case delete 9 16 10 17 var tabTitle: String { 11 18 switch self { 12 - case .run: "Run Script" 19 + case .script(let definition): definition.displayName 13 20 case .archive: "Archive Script" 14 21 case .delete: "Delete Script" 15 22 } ··· 17 24 18 25 var tabIcon: String { 19 26 switch self { 20 - case .run: "play.fill" 27 + case .script(let definition): definition.resolvedSystemImage 21 28 case .archive: "archivebox.fill" 22 29 case .delete: "trash.fill" 23 30 } ··· 25 32 26 33 var tabColor: TerminalTabTintColor { 27 34 switch self { 28 - case .run: .green 35 + case .script(let definition): definition.resolvedTintColor 29 36 case .archive: .orange 30 37 case .delete: .red 38 + } 39 + } 40 + 41 + /// The script definition ID for user-defined scripts, `nil` for lifecycle scripts. 42 + var scriptDefinitionID: UUID? { 43 + switch self { 44 + case .script(let definition): definition.id 45 + case .archive, .delete: nil 46 + } 47 + } 48 + 49 + /// `true` when this is a `.run`-kind script — the only kind 50 + /// stopped by the global Stop action (Cmd+.), since Stop is 51 + /// the semantic counterpart of Run. 52 + var isRunKind: Bool { 53 + switch self { 54 + case .script(let definition): definition.kind == .run 55 + case .archive, .delete: false 56 + } 57 + } 58 + } 59 + 60 + // MARK: - Hashable / Equatable 61 + 62 + extension BlockingScriptKind: Hashable { 63 + static func == (lhs: BlockingScriptKind, rhs: BlockingScriptKind) -> Bool { 64 + switch (lhs, rhs) { 65 + case (.script(let lhsDef), .script(let rhsDef)): lhsDef.id == rhsDef.id 66 + case (.archive, .archive): true 67 + case (.delete, .delete): true 68 + default: false 69 + } 70 + } 71 + 72 + func hash(into hasher: inout Hasher) { 73 + switch self { 74 + case .script(let definition): 75 + hasher.combine(0) 76 + hasher.combine(definition.id) 77 + case .archive: 78 + hasher.combine(1) 79 + case .delete: 80 + hasher.combine(2) 31 81 } 32 82 } 33 83 }
+1
supacode/Features/Terminal/Models/TerminalLayoutSnapshot.swift
··· 1 1 import Foundation 2 + import SupacodeSettingsShared 2 3 3 4 struct TerminalLayoutSnapshot: Codable, Equatable, Sendable { 4 5 let tabs: [TabSnapshot]
+1
supacode/Features/Terminal/Models/TerminalTabItem.swift
··· 1 1 import Foundation 2 + import SupacodeSettingsShared 2 3 3 4 struct TerminalTabItem: Identifiable, Equatable, Sendable { 4 5 let id: TerminalTabID
-17
supacode/Features/Terminal/Models/TerminalTabTintColor.swift
··· 1 - import SwiftUI 2 - 3 - /// Color token for terminal tab tint indicators, used in place of 4 - /// `Color` so that `TerminalTabItem` can remain `Equatable` and `Sendable`. 5 - enum TerminalTabTintColor: String, Codable, Hashable, Sendable { 6 - case green 7 - case orange 8 - case red 9 - 10 - var color: Color { 11 - switch self { 12 - case .green: .green 13 - case .orange: .orange 14 - case .red: .red 15 - } 16 - } 17 - }
+31 -3
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 175 175 let tabId = createTab( 176 176 TabCreation( 177 177 title: title, 178 - icon: "terminal", 178 + icon: nil, 179 179 isTitleLocked: false, 180 180 command: nil, 181 181 initialInput: resolvedInput, ··· 191 191 return tabId 192 192 } 193 193 194 + /// Stops a single user-defined script identified by its definition ID. 194 195 @discardableResult 195 - func stopRunScript() -> Bool { 196 - guard let tabId = blockingScripts.first(where: { $0.value == .run })?.key else { return false } 196 + func stopScript(definitionID: UUID) -> Bool { 197 + guard 198 + let tabId = blockingScripts.first(where: { $0.value.scriptDefinitionID == definitionID })?.key 199 + else { return false } 197 200 closeTab(tabId) 198 201 return true 202 + } 203 + 204 + /// Stops all running `.run`-kind scripts. Intentionally excludes 205 + /// non-run scripts (test, deploy, etc.) because the Stop action 206 + /// (Cmd+.) is the semantic counterpart of Run, not a "stop 207 + /// everything" command. Other kinds are stopped individually 208 + /// via the script menu or command palette. 209 + @discardableResult 210 + func stopRunScripts() -> Bool { 211 + let runTabIds = blockingScripts.filter { $0.value.isRunKind }.map(\.key) 212 + guard !runTabIds.isEmpty else { return false } 213 + for tabId in runTabIds { 214 + closeTab(tabId) 215 + } 216 + return true 217 + } 218 + 219 + /// Returns the set of script definition IDs currently running. 220 + func runningScriptDefinitionIDs() -> Set<UUID> { 221 + Set(blockingScripts.values.compactMap(\.scriptDefinitionID)) 222 + } 223 + 224 + /// Checks whether a user-defined script with the given definition ID is running. 225 + func isScriptRunning(definitionID: UUID) -> Bool { 226 + blockingScripts.values.contains(where: { $0.scriptDefinitionID == definitionID }) 199 227 } 200 228 201 229 @discardableResult
+1
supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift
··· 1 + import SupacodeSettingsShared 1 2 import SwiftUI 2 3 3 4 struct TerminalTabBackground: View {
+1
supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift
··· 1 + import SupacodeSettingsShared 1 2 import SwiftUI 2 3 3 4 struct TerminalTabLabelView: View {
+11 -11
supacodeTests/AgentHookSettingsFileInstallerTests.swift
··· 36 36 "type": "command", 37 37 "command": .string(AgentHookSettingsCommand.busyCommand(active: false)), 38 38 "timeout": 10, 39 - ]), 40 - ]), 41 - ]), 42 - ], 39 + ]) 40 + ]) 41 + ]) 42 + ] 43 43 ] 44 44 } 45 45 ··· 113 113 .object([ 114 114 "type": "command", 115 115 "command": "SUPACODE_CLI_PATH agent-hook --stop", 116 - ]), 117 - ]), 118 - ]), 119 - ]), 120 - ]), 116 + ]) 117 + ]) 118 + ]) 119 + ]) 120 + ]) 121 121 ]) 122 122 try fileManager.createDirectory( 123 123 at: url.deletingLastPathComponent(), ··· 163 163 .object([ 164 164 "type": "command", 165 165 "command": "echo third-party", 166 - ]), 167 - ]), 166 + ]) 167 + ]) 168 168 ])) 169 169 hooks["Stop"] = .array(stopGroups) 170 170 root["hooks"] = .object(hooks)
+7 -2
supacodeTests/AppFeatureArchivedSelectionTests.swift
··· 5 5 import Testing 6 6 7 7 @testable import SupacodeSettingsFeature 8 + @testable import SupacodeSettingsShared 8 9 @testable import supacode 9 10 10 11 @MainActor ··· 79 80 repositories: repositoriesState, 80 81 settings: SettingsFeature.State() 81 82 ) 82 - appState.repositories.runScriptWorktreeIDs = [activeWorktree.id, archivedWorktree.id] 83 + let scriptID = UUID() 84 + appState.repositories.runningScriptsByWorktreeID = [ 85 + activeWorktree.id: [scriptID], 86 + archivedWorktree.id: [scriptID], 87 + ] 83 88 let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 84 89 let store = TestStore(initialState: appState) { 85 90 AppFeature() ··· 92 97 store.exhaustivity = .off 93 98 94 99 await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) { 95 - $0.repositories.runScriptWorktreeIDs = [activeWorktree.id] 100 + $0.repositories.runningScriptsByWorktreeID = [activeWorktree.id: [scriptID]] 96 101 } 97 102 await store.finish() 98 103
+1 -1
supacodeTests/AppFeatureDeeplinkTests.swift
··· 165 165 166 166 await store.send(.deeplink(.worktree(id: worktree.id, action: .stop))) 167 167 await store.receive(\.repositories.selectWorktree) 168 - await store.receive(\.stopRunScript) 168 + await store.receive(\.stopRunScripts) 169 169 } 170 170 171 171 // MARK: - Help deeplink.
+11 -5
supacodeTests/AppFeatureDefaultEditorTests.swift
··· 37 37 await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) 38 38 await store.receive(\.worktreeSettingsLoaded) 39 39 #expect(store.state.openActionSelection == .finder) 40 - #expect(store.state.selectedRunScript == "") 40 + #expect(store.state.scripts.isEmpty) 41 41 await store.finish() 42 42 } 43 43 ··· 52 52 let repositoryID = worktree.repositoryRootURL.standardizedFileURL.path(percentEncoded: false) 53 53 var globalRepositorySettings = RepositorySettings.default 54 54 globalRepositorySettings.openActionID = OpenWorktreeAction.finder.settingsID 55 - var localRepositorySettings = RepositorySettings.default 56 - localRepositorySettings.openActionID = OpenWorktreeAction.terminal.settingsID 57 - localRepositorySettings.runScript = "pnpm dev" 55 + var localRepositorySettings = RepositorySettings( 56 + setupScript: "", 57 + archiveScript: "", 58 + deleteScript: "", 59 + runScript: "pnpm dev", 60 + scripts: [ScriptDefinition(kind: .run, command: "pnpm dev")], 61 + openActionID: OpenWorktreeAction.terminal.settingsID, 62 + worktreeBaseRef: nil 63 + ) 58 64 59 65 withDependencies { 60 66 $0.settingsFileStorage = settingsStorage.storage ··· 90 96 await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) 91 97 await store.receive(\.worktreeSettingsLoaded) { 92 98 $0.openActionSelection = .terminal 93 - $0.selectedRunScript = "pnpm dev" 99 + $0.scripts = localRepositorySettings.scripts 94 100 } 95 101 await store.finish() 96 102 }
+2 -2
supacodeTests/AppFeatureOpenWorktreeTests.swift
··· 36 36 #expect(context.openedActions.value.isEmpty) 37 37 #expect( 38 38 context.terminalCommands.value == [ 39 - .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: false), 39 + .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: false) 40 40 ] 41 41 ) 42 42 await store.finish() ··· 49 49 await store.receive(\.repositories.delegate.openWorktreeInApp) 50 50 #expect( 51 51 context.terminalCommands.value == [ 52 - .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: true), 52 + .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: true) 53 53 ] 54 54 ) 55 55 await store.finish()
+212 -49
supacodeTests/AppFeatureRunScriptTests.swift
··· 10 10 11 11 @MainActor 12 12 struct AppFeatureRunScriptTests { 13 - @Test(.dependencies) func runScriptWithoutConfiguredScriptPresentsPrompt() async { 13 + @Test(.dependencies) func runScriptWithoutConfiguredScriptsOpensSettings() async { 14 14 let worktree = makeWorktree() 15 15 let repositories = makeRepositoriesState(worktree: worktree) 16 + let expectedRepositoryID = worktree.repositoryRootURL.path(percentEncoded: false) 17 + var settingsState = SettingsFeature.State() 18 + settingsState.repositorySummaries = [ 19 + SettingsRepositorySummary(id: expectedRepositoryID, name: "repo") 20 + ] 16 21 let store = TestStore( 17 22 initialState: AppFeature.State( 18 23 repositories: repositories, 19 - settings: SettingsFeature.State() 24 + settings: settingsState 20 25 ) 21 26 ) { 22 27 AppFeature() 28 + } 29 + store.exhaustivity = .off 30 + 31 + await store.send(.runScript) 32 + await store.receive(\.settings.setSelection) 33 + #expect(store.state.settings.selection == .repositoryScripts(expectedRepositoryID)) 34 + } 35 + 36 + @Test(.dependencies) func runScriptRunsFirstRunKindScript() async { 37 + let worktree = makeWorktree() 38 + let repositories = makeRepositoriesState(worktree: worktree) 39 + let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") 40 + let sent = LockIsolated<[TerminalClient.Command]>([]) 41 + var initialState = AppFeature.State( 42 + repositories: repositories, 43 + settings: SettingsFeature.State() 44 + ) 45 + initialState.scripts = [definition] 46 + let store = TestStore(initialState: initialState) { 47 + AppFeature() 48 + } withDependencies: { 49 + $0.terminalClient.send = { command in 50 + sent.withValue { $0.append(command) } 51 + } 23 52 } 24 53 25 - await store.send(.runScript) { 26 - $0.runScriptDraft = "" 27 - $0.isRunScriptPromptPresented = true 54 + await store.send(.runScript) 55 + await store.receive(\.runNamedScript) { 56 + $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 57 + 58 + } 59 + await store.finish() 60 + 61 + #expect(sent.value.count == 1) 62 + guard case .runBlockingScript(let sentWorktree, let kind, let script) = sent.value.first else { 63 + Issue.record("Expected runBlockingScript command") 64 + return 65 + } 66 + #expect(sentWorktree == worktree) 67 + #expect(script == "npm run dev") 68 + guard case .script(let sentDefinition) = kind else { 69 + Issue.record("Expected .script kind") 70 + return 71 + } 72 + #expect(sentDefinition.kind == .run) 73 + #expect(sentDefinition.command == "npm run dev") 74 + } 75 + 76 + @Test(.dependencies) func runNamedScriptTracksRunningState() async { 77 + let worktree = makeWorktree() 78 + let repositories = makeRepositoriesState(worktree: worktree) 79 + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 80 + var initialState = AppFeature.State( 81 + repositories: repositories, 82 + settings: SettingsFeature.State() 83 + ) 84 + initialState.scripts = [definition] 85 + let store = TestStore(initialState: initialState) { 86 + AppFeature() 87 + } withDependencies: { 88 + $0.terminalClient.send = { _ in } 89 + } 90 + 91 + await store.send(.runNamedScript(definition)) { 92 + $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 28 93 } 94 + await store.finish() 29 95 } 30 96 31 - @Test(.dependencies) func saveRunScriptAndRunPersistsAndExecutesScript() async { 97 + @Test(.dependencies) func runNamedScriptRejectsDuplicateRun() async { 32 98 let worktree = makeWorktree() 33 99 let repositories = makeRepositoriesState(worktree: worktree) 34 - let storage = SettingsTestStorage() 100 + let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") 101 + var initialState = AppFeature.State( 102 + repositories: repositories, 103 + settings: SettingsFeature.State() 104 + ) 105 + initialState.scripts = [definition] 106 + // Pre-populate running state to simulate an already-running script. 107 + initialState.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 35 108 let sent = LockIsolated<[TerminalClient.Command]>([]) 36 - let store = withDependencies { 37 - $0.settingsFileStorage = storage.storage 38 - } operation: { 39 - TestStore( 40 - initialState: AppFeature.State( 41 - repositories: repositories, 42 - settings: SettingsFeature.State() 43 - ) 44 - ) { 45 - AppFeature() 46 - } withDependencies: { 47 - $0.terminalClient.send = { command in 48 - sent.withValue { $0.append(command) } 49 - } 109 + let store = TestStore(initialState: initialState) { 110 + AppFeature() 111 + } withDependencies: { 112 + $0.terminalClient.send = { command in 113 + sent.withValue { $0.append(command) } 50 114 } 51 115 } 52 116 53 - await store.send(.runScript) { 54 - $0.runScriptDraft = "" 55 - $0.isRunScriptPromptPresented = true 117 + // Second run of the same script should be silently rejected. 118 + await store.send(.runNamedScript(definition)) 119 + #expect(sent.value.isEmpty) 120 + } 121 + 122 + @Test(.dependencies) func scriptCompletedRemovesFromTracking() async { 123 + let worktree = makeWorktree() 124 + let repositories = makeRepositoriesState(worktree: worktree) 125 + let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") 126 + var repositoriesState = repositories 127 + repositoriesState.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 128 + 129 + let store = TestStore( 130 + initialState: AppFeature.State( 131 + repositories: repositoriesState, 132 + settings: SettingsFeature.State() 133 + ) 134 + ) { 135 + AppFeature() 136 + } 137 + 138 + await store.send( 139 + .repositories( 140 + .scriptCompleted( 141 + worktreeID: worktree.id, 142 + scriptID: definition.id, 143 + kind: .script(definition), 144 + exitCode: 0, 145 + tabId: nil 146 + ) 147 + ) 148 + ) { 149 + $0.repositories.runningScriptsByWorktreeID = [:] 150 + 56 151 } 57 - await store.send(.runScriptDraftChanged("npm run dev")) { 58 - $0.runScriptDraft = "npm run dev" 152 + } 153 + 154 + @Test(.dependencies) func stopRunScriptsCallsTerminalClient() async { 155 + let worktree = makeWorktree() 156 + let repositories = makeRepositoriesState(worktree: worktree) 157 + let sent = LockIsolated<[TerminalClient.Command]>([]) 158 + let store = TestStore( 159 + initialState: AppFeature.State( 160 + repositories: repositories, 161 + settings: SettingsFeature.State() 162 + ) 163 + ) { 164 + AppFeature() 165 + } withDependencies: { 166 + $0.terminalClient.send = { command in 167 + sent.withValue { $0.append(command) } 168 + } 59 169 } 60 - await store.send(.saveRunScriptAndRun) { 61 - $0.selectedRunScript = "npm run dev" 62 - $0.runScriptDraft = "" 63 - $0.isRunScriptPromptPresented = false 170 + 171 + await store.send(.stopRunScripts) 172 + await store.finish() 173 + 174 + #expect(sent.value.count == 1) 175 + guard case .stopRunScript(let sentWorktree) = sent.value.first else { 176 + Issue.record("Expected stopRunScript command") 177 + return 64 178 } 65 - await store.receive(\.runScript) { 66 - $0.repositories.runScriptWorktreeIDs = [worktree.id] 179 + #expect(sentWorktree == worktree) 180 + } 181 + 182 + @Test(.dependencies) func stopScriptSendsTerminalCommand() async { 183 + let worktree = makeWorktree() 184 + let repositories = makeRepositoriesState(worktree: worktree) 185 + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 186 + let sent = LockIsolated<[TerminalClient.Command]>([]) 187 + let store = TestStore( 188 + initialState: AppFeature.State( 189 + repositories: repositories, 190 + settings: SettingsFeature.State(), 191 + ), 192 + ) { 193 + AppFeature() 194 + } withDependencies: { 195 + $0.terminalClient.send = { command in 196 + sent.withValue { $0.append(command) } 197 + } 67 198 } 68 - await store.finish() 69 199 70 - #expect(sent.value == [.runBlockingScript(worktree, kind: .run, script: "npm run dev")]) 200 + await store.send(.stopScript(definition)) 201 + await store.finish() 71 202 72 - let savedRunScript = withDependencies { 73 - $0.settingsFileStorage = storage.storage 74 - } operation: { 75 - @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 76 - return repositorySettings.runScript 203 + #expect(sent.value.count == 1) 204 + guard case .stopScript(let sentWorktree, let definitionID) = sent.value.first else { 205 + Issue.record("Expected stopScript command") 206 + return 77 207 } 78 - #expect(savedRunScript == "npm run dev") 208 + #expect(sentWorktree == worktree) 209 + #expect(definitionID == definition.id) 79 210 } 80 211 81 - @Test(.dependencies) func runScriptDoesNotOverwriteDraftWhenPromptAlreadyPresented() async { 212 + @Test(.dependencies) func worktreeSettingsLoadedPopulatesScripts() async { 82 213 let worktree = makeWorktree() 83 214 let repositories = makeRepositoriesState(worktree: worktree) 215 + let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") 216 + var settings = RepositorySettings.default 217 + settings.scripts = [definition] 84 218 let store = TestStore( 85 219 initialState: AppFeature.State( 86 220 repositories: repositories, ··· 89 223 ) { 90 224 AppFeature() 91 225 } 226 + store.exhaustivity = .off 227 + 228 + await store.send(.worktreeSettingsLoaded(settings, worktreeID: worktree.id)) 229 + #expect(store.state.scripts == [definition]) 230 + } 92 231 93 - await store.send(.runScript) { 94 - $0.runScriptDraft = "" 95 - $0.isRunScriptPromptPresented = true 232 + @Test(.dependencies) func scriptCompletedCleansUpOrphanedIDAfterScriptDeletion() async { 233 + let worktree = makeWorktree() 234 + let repositories = makeRepositoriesState(worktree: worktree) 235 + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 236 + // Simulate a script that is running but has been removed from 237 + // the settings (e.g. user deleted it while it was executing). 238 + var repositoriesState = repositories 239 + repositoriesState.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 240 + 241 + let store = TestStore( 242 + initialState: AppFeature.State( 243 + repositories: repositoriesState, 244 + settings: SettingsFeature.State() 245 + ) 246 + ) { 247 + AppFeature() 96 248 } 97 - await store.send(.runScriptDraftChanged("pnpm dev")) { 98 - $0.runScriptDraft = "pnpm dev" 249 + // Scripts array is empty — the definition was deleted from settings. 250 + #expect(store.state.scripts.isEmpty) 251 + 252 + await store.send( 253 + .repositories( 254 + .scriptCompleted( 255 + worktreeID: worktree.id, 256 + scriptID: definition.id, 257 + kind: .script(definition), 258 + exitCode: 0, 259 + tabId: nil 260 + ) 261 + ) 262 + ) { 263 + $0.repositories.runningScriptsByWorktreeID = [:] 264 + 99 265 } 100 - await store.send(.runScript) 101 - #expect(store.state.runScriptDraft == "pnpm dev") 102 - #expect(store.state.isRunScriptPromptPresented) 103 266 } 104 267 105 268 private func makeWorktree() -> Worktree {
+90 -3
supacodeTests/CommandPaletteFeatureTests.swift
··· 2 2 import CustomDump 3 3 import Foundation 4 4 import IdentifiedCollections 5 + import SupacodeSettingsShared 5 6 import Testing 6 7 7 8 @testable import supacode ··· 49 50 copyIgnored: false, 50 51 copyUntracked: false 51 52 ) 52 - ), 53 + ) 53 54 ] 54 55 55 56 let items = CommandPaletteFeature.commandPaletteItems(from: state) ··· 74 75 description: "Focus the split to the right.", 75 76 action: "goto_split:right", 76 77 actionKey: "goto_split" 77 - ), 78 + ) 78 79 ] 79 80 ) 80 81 ··· 98 99 description: "", 99 100 action: "goto_split:right", 100 101 actionKey: "goto_split" 101 - ), 102 + ) 102 103 ] 103 104 ) 104 105 ··· 1000 1001 $0.recencyByItemID[item.id] = now.timeIntervalSince1970 1001 1002 } 1002 1003 await store.receive(.delegate(.ghosttyCommand("goto_split:right"))) 1004 + } 1005 + 1006 + // MARK: - Script items. 1007 + 1008 + @Test func commandPaletteItems_includesRunItemsForConfiguredScripts() { 1009 + let rootPath = "/tmp/repo" 1010 + let worktree = makeWorktree(id: rootPath, name: "repo", repoRoot: rootPath) 1011 + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) 1012 + var state = RepositoriesFeature.State(repositories: [repository]) 1013 + state.selection = .worktree(worktree.id) 1014 + 1015 + let runDef = ScriptDefinition(kind: .run, command: "npm run dev") 1016 + let testDef = ScriptDefinition(kind: .test, command: "npm test") 1017 + 1018 + let items = CommandPaletteFeature.commandPaletteItems( 1019 + from: state, 1020 + scripts: [runDef, testDef] 1021 + ) 1022 + 1023 + let runItem = items.first { $0.id == "script.\(runDef.id).run" } 1024 + let testItem = items.first { $0.id == "script.\(testDef.id).run" } 1025 + #expect(runItem?.title == "Run: Run") 1026 + #expect(testItem?.title == "Run: Test") 1027 + } 1028 + 1029 + @Test func commandPaletteItems_showsStopForRunningScripts() { 1030 + let rootPath = "/tmp/repo" 1031 + let worktree = makeWorktree(id: rootPath, name: "repo", repoRoot: rootPath) 1032 + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) 1033 + var state = RepositoriesFeature.State(repositories: [repository]) 1034 + state.selection = .worktree(worktree.id) 1035 + 1036 + let definition = ScriptDefinition(kind: .run, command: "npm run dev") 1037 + 1038 + let items = CommandPaletteFeature.commandPaletteItems( 1039 + from: state, 1040 + scripts: [definition], 1041 + runningScriptIDs: [definition.id] 1042 + ) 1043 + 1044 + let stopItem = items.first { $0.id == "script.\(definition.id).stop" } 1045 + #expect(stopItem?.title == "Stop: Run") 1046 + #expect(stopItem?.priorityTier == 0) 1047 + // No run item should exist for a running script. 1048 + let runItem = items.first { $0.id == "script.\(definition.id).run" } 1049 + #expect(runItem == nil) 1050 + } 1051 + 1052 + @Test func commandPaletteItems_excludesEmptyCommandScripts() { 1053 + let rootPath = "/tmp/repo" 1054 + let worktree = makeWorktree(id: rootPath, name: "repo", repoRoot: rootPath) 1055 + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) 1056 + var state = RepositoriesFeature.State(repositories: [repository]) 1057 + state.selection = .worktree(worktree.id) 1058 + 1059 + let emptyDef = ScriptDefinition(kind: .run, command: " ") 1060 + let validDef = ScriptDefinition(kind: .test, command: "npm test") 1061 + 1062 + let items = CommandPaletteFeature.commandPaletteItems( 1063 + from: state, 1064 + scripts: [emptyDef, validDef] 1065 + ) 1066 + 1067 + #expect(items.contains { $0.id == "script.\(emptyDef.id).run" } == false) 1068 + #expect(items.contains { $0.id == "script.\(validDef.id).run" }) 1069 + } 1070 + 1071 + @Test func commandPaletteItems_excludesScriptsWithoutSelectedWorktree() { 1072 + let definition = ScriptDefinition(kind: .run, command: "npm run dev") 1073 + let items = CommandPaletteFeature.commandPaletteItems( 1074 + from: RepositoriesFeature.State(), 1075 + scripts: [definition] 1076 + ) 1077 + 1078 + #expect(items.contains { $0.id == "script.\(definition.id).run" } == false) 1079 + } 1080 + 1081 + @Test func recencyRetentionIDs_includesScriptIDs() { 1082 + let definition = ScriptDefinition(kind: .run, command: "npm run dev") 1083 + let ids = CommandPaletteFeature.recencyRetentionIDs( 1084 + from: [], 1085 + scripts: [definition] 1086 + ) 1087 + 1088 + #expect(ids.contains("script.\(definition.id).run")) 1089 + #expect(ids.contains("script.\(definition.id).stop")) 1003 1090 } 1004 1091 } 1005 1092
+67 -23
supacodeTests/RepositoriesFeatureTests.swift
··· 884 884 stage: .loadingLocalBranches, 885 885 worktreeName: "feature/new-branch" 886 886 ) 887 - ), 887 + ) 888 888 ] 889 889 $0.selection = SidebarSelection.worktree(pendingID) 890 890 $0.sidebarSelectedWorktreeIDs = [pendingID] ··· 1505 1505 id: pendingID, 1506 1506 repositoryID: repository.id, 1507 1507 progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 1508 - ), 1508 + ) 1509 1509 ] 1510 1510 let store = TestStore(initialState: state) { 1511 1511 RepositoriesFeature() ··· 1542 1542 stage: .checkingRepositoryMode, 1543 1543 worktreeName: "swift-otter" 1544 1544 ) 1545 - ), 1545 + ) 1546 1546 ] 1547 1547 let store = TestStore(initialState: state) { 1548 1548 RepositoriesFeature() ··· 1739 1739 addedLines: nil, 1740 1740 removedLines: nil, 1741 1741 pullRequest: makePullRequest(state: "MERGED") 1742 - ), 1742 + ) 1743 1743 ] 1744 1744 let fixedDate = Date(timeIntervalSince1970: 1_000_000) 1745 1745 let store = TestStore(initialState: state) { ··· 1784 1784 await store.receive(\.delegate.runBlockingScript) 1785 1785 } 1786 1786 1787 - @Test(.dependencies) func runScriptCompletedWithFailureShowsAlert() async { 1787 + @Test(.dependencies) func scriptCompletedWithFailureShowsAlert() async { 1788 1788 let repoRoot = "/tmp/repo" 1789 1789 let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) 1790 1790 let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 1791 + let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") 1791 1792 var state = makeState(repositories: [repository]) 1792 - state.runScriptWorktreeIDs = [worktree.id] 1793 + state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 1794 + 1793 1795 let store = TestStore(initialState: state) { 1794 1796 RepositoriesFeature() 1795 1797 } 1796 1798 1797 - await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: 1, tabId: nil)) { 1798 - $0.runScriptWorktreeIDs = [] 1799 + await store.send( 1800 + .scriptCompleted( 1801 + worktreeID: worktree.id, 1802 + scriptID: definition.id, 1803 + kind: .script(definition), 1804 + exitCode: 1, 1805 + tabId: nil 1806 + ) 1807 + ) { 1808 + $0.runningScriptsByWorktreeID = [:] 1809 + 1799 1810 $0.alert = expectedScriptFailureAlert( 1800 - kind: .run, 1811 + kind: .script(definition), 1801 1812 exitMessage: "Script failed (exit code 1).", 1802 1813 worktreeID: worktree.id, 1803 1814 repoName: "repo", ··· 1806 1817 } 1807 1818 } 1808 1819 1809 - @Test(.dependencies) func runScriptCompletedWithSuccessDoesNotShowAlert() async { 1820 + @Test(.dependencies) func scriptCompletedWithSuccessDoesNotShowAlert() async { 1810 1821 let repoRoot = "/tmp/repo" 1811 1822 let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) 1812 1823 let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 1824 + let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") 1813 1825 var state = makeState(repositories: [repository]) 1814 - state.runScriptWorktreeIDs = [worktree.id] 1826 + state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 1827 + 1815 1828 let store = TestStore(initialState: state) { 1816 1829 RepositoriesFeature() 1817 1830 } 1818 1831 1819 - await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: 0, tabId: nil)) { 1820 - $0.runScriptWorktreeIDs = [] 1832 + await store.send( 1833 + .scriptCompleted( 1834 + worktreeID: worktree.id, 1835 + scriptID: definition.id, 1836 + kind: .script(definition), 1837 + exitCode: 0, 1838 + tabId: nil 1839 + ) 1840 + ) { 1841 + $0.runningScriptsByWorktreeID = [:] 1842 + 1821 1843 } 1822 1844 #expect(store.state.alert == nil) 1823 1845 } 1824 1846 1825 - @Test(.dependencies) func runScriptCompletedWithNilExitCodeDoesNotShowAlert() async { 1847 + @Test(.dependencies) func scriptCompletedWithNilExitCodeDoesNotShowAlert() async { 1826 1848 let repoRoot = "/tmp/repo" 1827 1849 let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) 1828 1850 let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 1851 + let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") 1829 1852 var state = makeState(repositories: [repository]) 1830 - state.runScriptWorktreeIDs = [worktree.id] 1853 + state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 1854 + 1831 1855 let store = TestStore(initialState: state) { 1832 1856 RepositoriesFeature() 1833 1857 } 1834 1858 1835 - await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: nil, tabId: nil)) { 1836 - $0.runScriptWorktreeIDs = [] 1859 + await store.send( 1860 + .scriptCompleted( 1861 + worktreeID: worktree.id, 1862 + scriptID: definition.id, 1863 + kind: .script(definition), 1864 + exitCode: nil, 1865 + tabId: nil 1866 + ) 1867 + ) { 1868 + $0.runningScriptsByWorktreeID = [:] 1869 + 1837 1870 } 1838 1871 #expect(store.state.alert == nil) 1839 1872 } ··· 1844 1877 let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) 1845 1878 let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 1846 1879 let tabId = TerminalTabID() 1880 + let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") 1847 1881 var state = makeState(repositories: [repository]) 1848 - state.runScriptWorktreeIDs = [worktree.id] 1882 + state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 1883 + 1849 1884 let store = TestStore(initialState: state) { 1850 1885 RepositoriesFeature() 1851 1886 } 1852 1887 store.exhaustivity = .off 1853 1888 1854 1889 // Trigger the failure alert through the normal flow. 1855 - await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: 1, tabId: tabId)) { 1856 - $0.runScriptWorktreeIDs = [] 1890 + await store.send( 1891 + .scriptCompleted( 1892 + worktreeID: worktree.id, 1893 + scriptID: definition.id, 1894 + kind: .script(definition), 1895 + exitCode: 1, 1896 + tabId: tabId 1897 + ) 1898 + ) { 1899 + $0.runningScriptsByWorktreeID = [:] 1900 + 1857 1901 $0.alert = expectedScriptFailureAlert( 1858 - kind: .run, 1902 + kind: .script(definition), 1859 1903 exitMessage: "Script failed (exit code 1).", 1860 1904 worktreeID: worktree.id, 1861 1905 tabId: tabId, ··· 2943 2987 id: removedWorktree.id, 2944 2988 repositoryID: repository.id, 2945 2989 progress: WorktreeCreationProgress(stage: .choosingWorktreeName) 2946 - ), 2990 + ) 2947 2991 ] 2948 2992 initialState.pinnedWorktreeIDs = [removedWorktree.id] 2949 2993 initialState.worktreeInfoByID = [ ··· 3026 3070 id: pendingID, 3027 3071 repositoryID: repository.id, 3028 3072 progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 3029 - ), 3073 + ) 3030 3074 ] 3031 3075 initialState.selection = .worktree(pendingID) 3032 3076 initialState.sidebarSelectedWorktreeIDs = [existingWorktree.id, pendingID]
+7 -7
supacodeTests/RepositorySettingsKeyTests.swift
··· 49 49 let rootURL = URL(fileURLWithPath: "/tmp/repo") 50 50 51 51 var updated = RepositorySettings.default 52 - updated.runScript = "echo updated" 52 + updated.setupScript = "echo updated" 53 53 54 54 withDependencies { 55 55 $0.settingsFileStorage = storage.storage ··· 146 146 let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 147 147 let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 148 148 var globalSettings = RepositorySettings.default 149 - globalSettings.runScript = "echo global" 149 + globalSettings.setupScript = "echo global" 150 150 var localSettings = RepositorySettings.default 151 - localSettings.runScript = "echo local" 151 + localSettings.setupScript = "echo local" 152 152 153 153 withDependencies { 154 154 $0.settingsFileStorage = globalStorage.storage ··· 185 185 let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 186 186 let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 187 187 var globalSettings = RepositorySettings.default 188 - globalSettings.runScript = "echo global" 188 + globalSettings.setupScript = "echo global" 189 189 190 190 withDependencies { 191 191 $0.settingsFileStorage = globalStorage.storage ··· 218 218 let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 219 219 let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 220 220 var globalSettings = RepositorySettings.default 221 - globalSettings.runScript = "echo global" 221 + globalSettings.setupScript = "echo global" 222 222 223 223 withDependencies { 224 224 $0.settingsFileStorage = globalStorage.storage ··· 256 256 try localStorage.save(encode(.default), at: localURL) 257 257 258 258 var updated = RepositorySettings.default 259 - updated.runScript = "echo local" 259 + updated.setupScript = "echo local" 260 260 261 261 withDependencies { 262 262 $0.settingsFileStorage = globalStorage.storage ··· 294 294 let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 295 295 296 296 var updated = RepositorySettings.default 297 - updated.runScript = "echo global" 297 + updated.setupScript = "echo global" 298 298 299 299 withDependencies { 300 300 $0.settingsFileStorage = globalStorage.storage
+258
supacodeTests/RepositorySettingsScriptTests.swift
··· 1 + import ComposableArchitecture 2 + import DependenciesTestSupport 3 + import Foundation 4 + import Testing 5 + 6 + @testable import SupacodeSettingsFeature 7 + @testable import SupacodeSettingsShared 8 + 9 + // MARK: - Codable migration tests. 10 + 11 + struct RepositorySettingsCodableTests { 12 + @Test func decodeFromLegacyRunScriptOnly() throws { 13 + // JSON with only `runScript` and no `scripts` key should produce 14 + // a single `.run`-kind ScriptDefinition. 15 + let json = """ 16 + { 17 + "setupScript": "", 18 + "archiveScript": "", 19 + "deleteScript": "", 20 + "runScript": "npm start", 21 + "openActionID": "automatic" 22 + } 23 + """ 24 + let data = Data(json.utf8) 25 + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) 26 + #expect(settings.scripts.count == 1) 27 + #expect(settings.scripts.first?.kind == .run) 28 + #expect(settings.scripts.first?.command == "npm start") 29 + } 30 + 31 + @Test func decodeWithBothRunScriptAndScripts() throws { 32 + // When both `runScript` and `scripts` are present, `scripts` wins. 33 + let json = """ 34 + { 35 + "setupScript": "", 36 + "archiveScript": "", 37 + "deleteScript": "", 38 + "runScript": "legacy command", 39 + "scripts": [ 40 + { 41 + "id": "00000000-0000-0000-0000-000000000001", 42 + "kind": "test", "name": "Test", 43 + "systemImage": "checkmark.diamond.fill", 44 + "tintColor": "blue", "command": "npm test" 45 + } 46 + ], 47 + "openActionID": "automatic" 48 + } 49 + """ 50 + let data = Data(json.utf8) 51 + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) 52 + #expect(settings.scripts.count == 1) 53 + #expect(settings.scripts.first?.kind == .test) 54 + #expect(settings.scripts.first?.command == "npm test") 55 + } 56 + 57 + @Test func encodeRoundTripPopulatesRunScript() throws { 58 + // Encoding settings with scripts should derive `runScript` from 59 + // the first `.run`-kind script's command. 60 + var settings = RepositorySettings.default 61 + settings.scripts = [ 62 + ScriptDefinition(kind: .test, command: "npm test"), 63 + ScriptDefinition(kind: .run, command: "npm run dev"), 64 + ] 65 + let data = try JSONEncoder().encode(settings) 66 + let raw = try JSONDecoder().decode([String: AnyCodable].self, from: data) 67 + #expect(raw["runScript"]?.stringValue == "npm run dev") 68 + } 69 + 70 + @Test func encodeWithNoRunKindScriptClearsRunScript() throws { 71 + // When no `.run`-kind script exists, the encoded `runScript` 72 + // should be empty — not the stale legacy value. 73 + var settings = RepositorySettings( 74 + setupScript: "", 75 + archiveScript: "", 76 + deleteScript: "", 77 + runScript: "stale legacy command", 78 + scripts: [ScriptDefinition(kind: .test, command: "npm test")], 79 + openActionID: "automatic", 80 + worktreeBaseRef: nil 81 + ) 82 + let data = try JSONEncoder().encode(settings) 83 + let raw = try JSONDecoder().decode([String: AnyCodable].self, from: data) 84 + #expect(raw["runScript"]?.stringValue == "") 85 + } 86 + 87 + @Test func decodeWithUnknownScriptKindDropsOnlyInvalidEntries() throws { 88 + // An unknown `kind` value should only drop that entry, not the 89 + // entire array. Valid sibling scripts must survive. 90 + let json = """ 91 + { 92 + "setupScript": "", 93 + "archiveScript": "", 94 + "deleteScript": "", 95 + "runScript": "", 96 + "scripts": [ 97 + { 98 + "id": "00000000-0000-0000-0000-000000000001", 99 + "kind": "run", "name": "Run", 100 + "systemImage": "play", 101 + "tintColor": "green", "command": "npm start" 102 + }, 103 + { 104 + "id": "00000000-0000-0000-0000-000000000002", 105 + "kind": "unknown_future_kind", "name": "X", 106 + "systemImage": "star", 107 + "tintColor": "red", "command": "echo hi" 108 + }, 109 + { 110 + "id": "00000000-0000-0000-0000-000000000003", 111 + "kind": "test", "name": "Test", 112 + "systemImage": "play.diamond", 113 + "tintColor": "yellow", "command": "npm test" 114 + } 115 + ], 116 + "openActionID": "automatic" 117 + } 118 + """ 119 + let data = Data(json.utf8) 120 + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) 121 + #expect(settings.scripts.count == 2) 122 + #expect(settings.scripts[0].kind == .run) 123 + #expect(settings.scripts[0].command == "npm start") 124 + #expect(settings.scripts[1].kind == .test) 125 + #expect(settings.scripts[1].command == "npm test") 126 + } 127 + } 128 + 129 + /// Lightweight type-erased wrapper for JSON inspection in tests. 130 + private struct AnyCodable: Decodable { 131 + let value: Any 132 + 133 + var stringValue: String? { value as? String } 134 + 135 + init(from decoder: Decoder) throws { 136 + let container = try decoder.singleValueContainer() 137 + if let string = try? container.decode(String.self) { 138 + value = string 139 + } else if let int = try? container.decode(Int.self) { 140 + value = int 141 + } else if let bool = try? container.decode(Bool.self) { 142 + value = bool 143 + } else if let array = try? container.decode([AnyCodable].self) { 144 + value = array.map(\.value) 145 + } else if let dict = try? container.decode([String: AnyCodable].self) { 146 + value = dict.mapValues(\.value) 147 + } else { 148 + value = NSNull() 149 + } 150 + } 151 + } 152 + 153 + // MARK: - Feature tests. 154 + 155 + @MainActor 156 + struct RepositorySettingsScriptTests { 157 + private static let rootURL = URL(filePath: "/tmp/test-repo") 158 + 159 + private func makeStore( 160 + scripts: [ScriptDefinition] = [] 161 + ) -> TestStore<RepositorySettingsFeature.State, RepositorySettingsFeature.Action> { 162 + var settings = RepositorySettings.default 163 + settings.scripts = scripts 164 + return TestStore( 165 + initialState: RepositorySettingsFeature.State( 166 + rootURL: Self.rootURL, 167 + settings: settings, 168 + ), 169 + ) { 170 + RepositorySettingsFeature() 171 + } 172 + } 173 + 174 + @Test(.dependencies) func addScriptAppendsCustomScript() async { 175 + let store = makeStore() 176 + store.exhaustivity = .off(showSkippedAssertions: false) 177 + 178 + await store.send(.addScript(.custom)) { 179 + #expect($0.settings.scripts.count == 1) 180 + #expect($0.settings.scripts.first?.kind == .custom) 181 + #expect($0.settings.scripts.first?.name == "Custom") 182 + } 183 + } 184 + 185 + @Test(.dependencies) func addScriptRejectsDuplicatePredefinedKind() async { 186 + let store = makeStore(scripts: [ScriptDefinition(kind: .lint, command: "swiftlint")]) 187 + store.exhaustivity = .off(showSkippedAssertions: false) 188 + 189 + // Second .lint is silently rejected. 190 + await store.send(.addScript(.lint)) 191 + #expect(store.state.settings.scripts.count == 1) 192 + } 193 + 194 + @Test(.dependencies) func addScriptAllowsMultipleCustomKinds() async { 195 + let store = makeStore(scripts: [ScriptDefinition(kind: .custom, name: "A", command: "a")]) 196 + store.exhaustivity = .off(showSkippedAssertions: false) 197 + 198 + await store.send(.addScript(.custom)) { 199 + #expect($0.settings.scripts.count == 2) 200 + } 201 + } 202 + 203 + @Test(.dependencies) func removeScriptShowsConfirmationAndRemovesByID() async { 204 + let script1 = ScriptDefinition(kind: .run, command: "npm run dev") 205 + let script2 = ScriptDefinition(kind: .test, command: "npm test") 206 + let script3 = ScriptDefinition(kind: .deploy, command: "deploy.sh") 207 + let store = makeStore(scripts: [script1, script2, script3]) 208 + store.exhaustivity = .off(showSkippedAssertions: false) 209 + 210 + await store.send(.removeScript(script2.id)) { 211 + $0.alert = AlertState { 212 + TextState("Remove \"\(script2.displayName)\" script?") 213 + } actions: { 214 + ButtonState(role: .destructive, action: .confirmRemoveScript(script2.id)) { 215 + TextState("Remove") 216 + } 217 + ButtonState(role: .cancel) { 218 + TextState("Cancel") 219 + } 220 + } message: { 221 + TextState("This action cannot be undone.") 222 + } 223 + } 224 + 225 + await store.send(.alert(.presented(.confirmRemoveScript(script2.id)))) { 226 + $0.alert = nil 227 + $0.settings.scripts = [script1, script3] 228 + } 229 + } 230 + 231 + @Test(.dependencies) func removeScriptCancelDoesNotRemove() async { 232 + let script = ScriptDefinition(kind: .run, command: "npm run dev") 233 + let store = makeStore(scripts: [script]) 234 + store.exhaustivity = .off(showSkippedAssertions: false) 235 + 236 + await store.send(.removeScript(script.id)) { 237 + $0.alert = AlertState { 238 + TextState("Remove \"\(script.displayName)\" script?") 239 + } actions: { 240 + ButtonState(role: .destructive, action: .confirmRemoveScript(script.id)) { 241 + TextState("Remove") 242 + } 243 + ButtonState(role: .cancel) { 244 + TextState("Cancel") 245 + } 246 + } message: { 247 + TextState("This action cannot be undone.") 248 + } 249 + } 250 + 251 + await store.send(.alert(.dismiss)) { 252 + $0.alert = nil 253 + } 254 + 255 + #expect(store.state.settings.scripts.count == 1) 256 + } 257 + 258 + }
+1 -1
supacodeTests/TerminalLayoutSnapshotTests.swift
··· 89 89 tintColor: nil, 90 90 layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/home")), 91 91 focusedLeafIndex: 0 92 - ), 92 + ) 93 93 ], 94 94 selectedTabIndex: 0 95 95 )
+1
supacodeTests/TerminalTabManagerTests.swift
··· 1 + import SupacodeSettingsShared 1 2 import Testing 2 3 3 4 @testable import supacode
+15 -11
supacodeTests/WorktreeTerminalManagerTests.swift
··· 1 1 import Dependencies 2 2 import Foundation 3 + import SupacodeSettingsShared 3 4 import Testing 4 5 5 6 @testable import supacode ··· 166 167 title: "Unread", 167 168 body: "body", 168 169 isRead: false 169 - ), 170 + ) 170 171 ] 171 172 state.onNotificationIndicatorChanged?() 172 173 state.notifications = [ ··· 175 176 title: "Read", 176 177 body: "body", 177 178 isRead: true 178 - ), 179 + ) 179 180 ] 180 181 181 182 let stream = manager.eventStream() ··· 588 589 @Test func runScriptBlockingScriptTracksRunningState() { 589 590 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 590 591 let worktree = makeWorktree() 592 + let definition = ScriptDefinition(kind: .run, command: "echo hi") 591 593 592 - #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == false) 594 + #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == false) 593 595 594 - manager.handleCommand(.runBlockingScript(worktree, kind: .run, script: "echo hi")) 596 + manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "echo hi")) 595 597 596 - #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == true) 598 + #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == true) 597 599 } 598 600 599 601 @Test func stopRunScriptClosesRunTab() { 600 602 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 601 603 let worktree = makeWorktree() 604 + let definition = ScriptDefinition(kind: .run, command: "sleep 10") 602 605 603 - manager.handleCommand(.runBlockingScript(worktree, kind: .run, script: "sleep 10")) 604 - #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == true) 606 + manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "sleep 10")) 607 + #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == true) 605 608 606 609 manager.handleCommand(.stopRunScript(worktree)) 607 - #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == false) 610 + #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == false) 608 611 } 609 612 610 613 @Test func runScriptTabTitleResetsAfterSignalInterruption() async { 611 614 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 612 615 let worktree = makeWorktree() 613 616 let stream = manager.eventStream() 617 + let definition = ScriptDefinition(kind: .run, command: "sleep 10") 614 618 615 - manager.handleCommand(.runBlockingScript(worktree, kind: .run, script: "sleep 10")) 619 + manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "sleep 10")) 616 620 617 621 guard let state = manager.stateIfExists(for: worktree.id), 618 622 let tabId = state.tabManager.selectedTabId, ··· 623 627 } 624 628 625 629 let tab = state.tabManager.tabs.first { $0.id == tabId } 626 - #expect(tab?.title == "Run Script") 630 + #expect(tab?.title == "Run") 627 631 #expect(tab?.isTitleLocked == true) 628 632 #expect(tab?.tintColor == .green) 629 633 ··· 841 845 ) 842 846 ), 843 847 focusedLeafIndex: 0 844 - ), 848 + ) 845 849 ], 846 850 selectedTabIndex: 0 847 851 )