diff --git a/.swiftlint.yml b/.swiftlint.yml index b5de4f039..bd6b581fe 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,10 +4,13 @@ included: - supacode - supacode-cli - supacodeTests + - SupacodeSettingsShared + - SupacodeSettingsFeature excluded: - ThirdParty/ghostty # Skill content contains long markdown lines inside multiline strings. - supacode/Features/Settings/BusinessLogic/CLISkillContent.swift + - SupacodeSettingsShared/BusinessLogic/CLISkillContent.swift disabled_rules: - file_length diff --git a/Makefile b/Makefile index b0dbd26e7..6262e5cf4 100644 --- a/Makefile +++ b/Makefile @@ -117,8 +117,8 @@ test: $(TUIST_DEVELOPMENT_GENERATION_STAMP) # Run all tests 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; \ fi -format: # Format code with swift format (local only) - swift format -p --in-place --recursive --configuration ./.swift-format.json supacode supacode-cli supacodeTests +format: # Format code with swift format (local only). + swift format -p --in-place --recursive --configuration ./.swift-format.json supacode supacode-cli supacodeTests SupacodeSettingsShared SupacodeSettingsFeature lint: # Lint code with swiftlint mise exec -- swiftlint lint --quiet --config .swiftlint.yml diff --git a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift index 8307259a7..f38c28fb8 100644 --- a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift @@ -27,6 +27,8 @@ public struct RepositorySettingsFeature { ) } + @Presents public var alert: AlertState? + public init( rootURL: URL, settings: RepositorySettings, @@ -52,6 +54,11 @@ public struct RepositorySettingsFeature { } } + @CasePathable + public enum Alert: Equatable { + case confirmRemoveScript(ScriptDefinition.ID) + } + public enum Action: BindableAction { case task case settingsLoaded( @@ -63,6 +70,9 @@ public struct RepositorySettingsFeature { globalPullRequestMergeStrategy: PullRequestMergeStrategy ) case branchDataLoaded([String], defaultBaseRef: String) + case addScript(ScriptKind) + case removeScript(ScriptDefinition.ID) + case alert(PresentationAction) case delegate(Delegate) case binding(BindingAction) } @@ -160,24 +170,61 @@ public struct RepositorySettingsFeature { state.isBranchDataLoaded = true return .none + case .addScript(let kind): + // Predefined kinds are unique; reject duplicates. + guard kind == .custom || !state.settings.scripts.contains(where: { $0.kind == kind }) else { + return .none + } + state.settings.scripts.append(ScriptDefinition(kind: kind)) + return persistAndNotify(state: &state) + + case .removeScript(let id): + guard let script = state.settings.scripts.first(where: { $0.id == id }) else { return .none } + state.alert = AlertState { + TextState("Remove \"\(script.displayName)\" script?") + } actions: { + ButtonState(role: .destructive, action: .confirmRemoveScript(id)) { + TextState("Remove") + } + ButtonState(role: .cancel) { + TextState("Cancel") + } + } message: { + TextState("This action cannot be undone.") + } + return .none + + case .alert(.presented(.confirmRemoveScript(let id))): + state.settings.scripts.removeAll { $0.id == id } + return persistAndNotify(state: &state) + + case .alert: + return .none + case .binding: if state.isBareRepository { state.settings.copyIgnoredOnWorktreeCreate = nil state.settings.copyUntrackedOnWorktreeCreate = nil } - let rootURL = state.rootURL - var normalizedSettings = state.settings - normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( - normalizedSettings.worktreeBaseDirectoryPath, - repositoryRootURL: rootURL - ) - @Shared(.repositorySettings(rootURL)) var repositorySettings - $repositorySettings.withLock { $0 = normalizedSettings } - return .send(.delegate(.settingsChanged(rootURL))) + return persistAndNotify(state: &state) case .delegate: return .none } } + .ifLet(\.$alert, action: \.alert) + } + + /// Persists the current settings and notifies the delegate. + private func persistAndNotify(state: inout State) -> Effect { + let rootURL = state.rootURL + var normalizedSettings = state.settings + normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( + normalizedSettings.worktreeBaseDirectoryPath, + repositoryRootURL: rootURL + ) + @Shared(.repositorySettings(rootURL)) var repositorySettings + $repositorySettings.withLock { $0 = normalizedSettings } + return .send(.delegate(.settingsChanged(rootURL))) } } diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index c7e6b4631..4e6a24778 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -546,7 +546,7 @@ public struct SettingsFeature { state.repositorySettings = nil return } - guard case .repository(let repositoryID) = selection else { + guard let repositoryID = selection.repositoryID else { state.repositorySettings = nil return } diff --git a/SupacodeSettingsFeature/Views/AppearanceOptionCardView.swift b/SupacodeSettingsFeature/Views/AppearanceOptionCardView.swift index d1fe71fa1..071ee6d20 100644 --- a/SupacodeSettingsFeature/Views/AppearanceOptionCardView.swift +++ b/SupacodeSettingsFeature/Views/AppearanceOptionCardView.swift @@ -1,5 +1,5 @@ -import SwiftUI import SupacodeSettingsShared +import SwiftUI struct AppearanceOptionCardView: View { let mode: AppearanceMode diff --git a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift new file mode 100644 index 000000000..cb2a7c3a6 --- /dev/null +++ b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift @@ -0,0 +1,145 @@ +import ComposableArchitecture +import SupacodeSettingsShared +import SwiftUI + +/// Settings sub-section for managing on-demand and lifecycle scripts. +public struct RepositoryScriptsSettingsView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + Form { + // Lifecycle scripts. + LifecycleScriptSection( + text: $store.settings.setupScript, + title: "Setup Script", + subtitle: "Runs once after worktree creation.", + icon: "truck.box.badge.clock", + iconColor: .blue, + footerExample: "pnpm install" + ) + LifecycleScriptSection( + text: $store.settings.archiveScript, + title: "Archive Script", + subtitle: "Runs before a worktree is archived.", + icon: "archivebox", + iconColor: .orange, + footerExample: "docker compose down" + ) + LifecycleScriptSection( + text: $store.settings.deleteScript, + title: "Delete Script", + subtitle: "Runs before a worktree is deleted.", + icon: "trash", + iconColor: .red, + footerExample: "docker compose down" + ) + + // User-defined scripts, each in its own section. + ForEach($store.settings.scripts) { $script in + Section { + if script.kind == .custom { + TextField("Name", text: $script.name) + } + ScriptCommandEditor(text: $script.command, label: script.displayName) + Button("Remove Script…", role: .destructive) { + store.send(.removeScript(script.id)) + } + .buttonStyle(.plain) + .foregroundStyle(.red) + .help("Remove this script.") + } header: { + Label { + Text("\(script.displayName) Script") + .font(.body) + .bold() + } icon: { + Image(systemName: script.resolvedSystemImage).foregroundStyle(script.resolvedTintColor.color) + .accessibilityHidden(true) + }.labelStyle(.verticallyCentered) + } + } + + } + .alert($store.scope(state: \.alert, action: \.alert)) + .formStyle(.grouped) + .padding(.top, -20) + .padding(.leading, -8) + .padding(.trailing, -6) + .toolbar { + ToolbarItem(placement: .primaryAction) { + let usedKinds = Set(store.settings.scripts.map(\.kind)) + Menu { + ForEach(ScriptKind.allCases, id: \.self) { kind in + if kind == .custom || !usedKinds.contains(kind) { + Button { + store.send(.addScript(kind)) + } label: { + Label { + Text("\(kind.defaultName) Script") + } icon: { + Image.tintedSymbol(kind.defaultSystemImage, color: kind.defaultTintColor.nsColor) + } + } + } + } + } label: { + Image(systemName: "plus") + .accessibilityLabel("Add Script") + } + .help("Add a new script.") + } + } + } +} + +/// Reusable section for lifecycle scripts (setup, archive, delete). +private struct LifecycleScriptSection: View { + @Binding var text: String + let title: String + let subtitle: String + let icon: String + let iconColor: Color + let footerExample: String + + var body: some View { + Section { + ScriptCommandEditor(text: $text, label: title) + } header: { + Label { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(.body) + .bold() + .lineLimit(1) + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } icon: { + Image(systemName: icon).foregroundStyle(iconColor).accessibilityHidden(true) + }.labelStyle(.verticallyCentered) + } footer: { + Text("e.g., `\(footerExample)`") + } + } +} + +/// Monospaced text editor for script commands. +private struct ScriptCommandEditor: View { + @Binding var text: String + let label: String + + var body: some View { + TextEditor(text: $text) + .monospaced() + .textEditorStyle(.plain) + .autocorrectionDisabled() + .frame(height: 90) + .accessibilityLabel(label) + } +} diff --git a/SupacodeSettingsFeature/Views/RepositorySettingsView.swift b/SupacodeSettingsFeature/Views/RepositorySettingsView.swift index d11a1ee75..746e7a9d1 100644 --- a/SupacodeSettingsFeature/Views/RepositorySettingsView.swift +++ b/SupacodeSettingsFeature/Views/RepositorySettingsView.swift @@ -108,30 +108,6 @@ public struct RepositorySettingsView: View { description: "Path to the repository root." ) } - ScriptSection( - title: "Setup Script", - subtitle: "Runs once after worktree creation.", - text: settings.setupScript, - placeholder: "claude --dangerously-skip-permissions" - ) - ScriptSection( - title: "Run Script", - subtitle: "Launched on demand from the toolbar.", - text: settings.runScript, - placeholder: "npm run dev" - ) - ScriptSection( - title: "Archive Script", - subtitle: "Runs before a worktree is archived.", - text: settings.archiveScript, - placeholder: "docker compose down" - ) - ScriptSection( - title: "Delete Script", - subtitle: "Runs before a worktree is deleted.", - text: settings.deleteScript, - placeholder: "docker compose down" - ) } .formStyle(.grouped) .padding(.top, -20) @@ -143,31 +119,6 @@ public struct RepositorySettingsView: View { } } -// MARK: - Script section. - -private struct ScriptSection: View { - let title: String - let subtitle: String - let text: Binding - let placeholder: String - - var body: some View { - Section { - TextEditor(text: text) - .monospaced() - .textEditorStyle(.plain) - .autocorrectionDisabled() - .frame(height: 112) - .accessibilityLabel(title) - } header: { - Text(title) - Text(subtitle) - } footer: { - Text("e.g., `\(placeholder)`") - } - } -} - // MARK: - Environment row. private struct ScriptEnvironmentRow: View { diff --git a/SupacodeSettingsFeature/Views/SettingsSection.swift b/SupacodeSettingsFeature/Views/SettingsSection.swift index 1a871d8be..e621644f4 100644 --- a/SupacodeSettingsFeature/Views/SettingsSection.swift +++ b/SupacodeSettingsFeature/Views/SettingsSection.swift @@ -9,4 +9,15 @@ public enum SettingsSection: Hashable { case updates case github case repository(String) + case repositoryScripts(String) + + /// The repository ID for repository-scoped sections. + public var repositoryID: String? { + switch self { + case .repository(let id), .repositoryScripts(let id): + id + default: + nil + } + } } diff --git a/SupacodeSettingsShared/App/AppShortcutOverride.swift b/SupacodeSettingsShared/App/AppShortcutOverride.swift index bb6c5685f..ba9d719e6 100644 --- a/SupacodeSettingsShared/App/AppShortcutOverride.swift +++ b/SupacodeSettingsShared/App/AppShortcutOverride.swift @@ -32,8 +32,8 @@ public nonisolated struct AppShortcutOverride: Codable, Equatable, Hashable, Sen // MARK: - SwiftUI conversions. -public extension AppShortcutOverride { - init(from eventModifiers: SwiftUI.EventModifiers, keyCode: UInt16) { +extension AppShortcutOverride { + public init(from eventModifiers: SwiftUI.EventModifiers, keyCode: UInt16) { self.keyCode = keyCode var flags: ModifierFlags = [] if eventModifiers.contains(.command) { flags.insert(.command) } @@ -44,7 +44,7 @@ public extension AppShortcutOverride { self.isEnabled = true } - var eventModifiers: SwiftUI.EventModifiers { + public var eventModifiers: SwiftUI.EventModifiers { var result: SwiftUI.EventModifiers = [] if modifiers.contains(.command) { result.insert(.command) } if modifiers.contains(.option) { result.insert(.option) } @@ -53,28 +53,28 @@ public extension AppShortcutOverride { return result } - var keyboardShortcut: KeyboardShortcut { + public var keyboardShortcut: KeyboardShortcut { KeyboardShortcut(keyEquivalent, modifiers: eventModifiers) } - var keyEquivalent: KeyEquivalent { + public var keyEquivalent: KeyEquivalent { Self.keyEquivalent(for: keyCode) } } // MARK: - Display. -public extension AppShortcutOverride { - var displayString: String { +extension AppShortcutOverride { + public var displayString: String { Self.displaySymbols(for: keyCode, modifiers: modifiers).joined() } // Ordered array of individual display symbols: one per modifier, followed by the key. - var displaySymbols: [String] { + public var displaySymbols: [String] { Self.displaySymbols(for: keyCode, modifiers: modifiers) } - static func displaySymbols(for keyCode: UInt16, modifiers: ModifierFlags) -> [String] { + public static func displaySymbols(for keyCode: UInt16, modifiers: ModifierFlags) -> [String] { var parts: [String] = [] if modifiers.contains(.command) { parts.append("⌘") } if modifiers.contains(.shift) { parts.append("⇧") } @@ -87,18 +87,18 @@ public extension AppShortcutOverride { // MARK: - System hotkeys. -public extension AppShortcutOverride { +extension AppShortcutOverride { // Well-known macOS app conventions always reserved by AppKit (not in the symbolic hotkeys plist). - static let appKitReservedDisplayStrings: Set = ["⌘Q", "⌘W", "⌘H", "⌘M"] + public static let appKitReservedDisplayStrings: Set = ["⌘Q", "⌘W", "⌘H", "⌘M"] // Reads macOS system symbolic hotkeys at runtime and returns their display strings, // combined with well-known AppKit reserved shortcuts. - static func allReservedDisplayStrings() -> Set { + public static func allReservedDisplayStrings() -> Set { systemReservedDisplayStrings().union(appKitReservedDisplayStrings) } // Reads macOS system symbolic hotkeys at runtime and returns their display strings. - static func systemReservedDisplayStrings() -> Set { + public static func systemReservedDisplayStrings() -> Set { guard let defaults = UserDefaults(suiteName: "com.apple.symbolichotkeys"), let hotkeys = defaults.dictionary(forKey: "AppleSymbolicHotKeys") else { @@ -131,8 +131,8 @@ public extension AppShortcutOverride { // MARK: - Ghostty keybind. -public extension AppShortcutOverride { - var ghosttyKeybind: String { +extension AppShortcutOverride { + public var ghosttyKeybind: String { let parts = ghosttyModifierParts + [Self.ghosttyKeyName(for: keyCode)] return parts.joined(separator: "+") } @@ -151,9 +151,9 @@ public extension AppShortcutOverride { private nonisolated let shortcutLogger = SupaLogger("Shortcuts") -public extension AppShortcutOverride { +extension AppShortcutOverride { // Reverse lookup: given a US QWERTY character, return its key code. - static func keyCode(for character: Character) -> UInt16? { + public static func keyCode(for character: Character) -> UInt16? { reverseUSQwerty[character] } @@ -167,13 +167,13 @@ public extension AppShortcutOverride { // Resolves the character for a key code using the current keyboard layout, // falling back to US QWERTY when the layout is unavailable (e.g., CI, sandboxed contexts). - static func layoutCharacter(for code: UInt16) -> String? { + public static func layoutCharacter(for code: UInt16) -> String? { if let char = currentLayoutCharacter(for: code, modifierState: 0) { return char } shortcutLogger.debug("Using US QWERTY fallback for key code \(code)") return usQwertyFallback[code] } - static func displayCharacter(for keyEquivalent: KeyEquivalent) -> String { + public static func displayCharacter(for keyEquivalent: KeyEquivalent) -> String { guard let code = keyCode(forDisplayedKeyEquivalent: keyEquivalent.character) else { return String(keyEquivalent.character).uppercased() } @@ -181,7 +181,7 @@ public extension AppShortcutOverride { } // The Ghostty key name for a given key code (e.g. "a", "arrow_up", "return"). - static func resolvedGhosttyKeyName(for code: UInt16) -> String { + public static func resolvedGhosttyKeyName(for code: UInt16) -> String { ghosttyKeyName(for: code) } @@ -269,7 +269,7 @@ public extension AppShortcutOverride { // AppKit renders menu key equivalents from the logical key equivalent. Reverse // lookup the active layout so our own labels match the menu bar. - static func keyCode( + public static func keyCode( forDisplayedKeyEquivalent character: Character, candidateKeyCodes: [UInt16] = candidatePrintableKeyCodes, modifierStates: [UInt32] = menuDisplayModifierStates, @@ -288,7 +288,7 @@ public extension AppShortcutOverride { return nil } - static func keyCode(forDisplayedKeyEquivalent character: Character) -> UInt16? { + public static func keyCode(forDisplayedKeyEquivalent character: Character) -> UInt16? { keyCode(forDisplayedKeyEquivalent: character) { code, modifierState in currentLayoutCharacter(for: code, modifierState: modifierState) } @@ -319,8 +319,8 @@ public extension AppShortcutOverride { // UCKeyTranslate modifier states: unmodified, shift, option, shift+option. // Ordered so the simplest printable mapping is preferred during reverse lookup. - static let menuDisplayModifierStates: [UInt32] = [0, 0x02, 0x08, 0x0A] - static let candidatePrintableKeyCodes: [UInt16] = Array(usQwertyFallback.keys).sorted() + public static let menuDisplayModifierStates: [UInt32] = [0, 0x02, 0x08, 0x0A] + public static let candidatePrintableKeyCodes: [UInt16] = Array(usQwertyFallback.keys).sorted() private static func ghosttyKeyName(for code: UInt16) -> String { switch Int(code) { @@ -337,7 +337,7 @@ public extension AppShortcutOverride { } } - static func displayCharacter(for code: UInt16, modifiers: ModifierFlags = []) -> String { + public static func displayCharacter(for code: UInt16, modifiers: ModifierFlags = []) -> String { switch Int(code) { case kVK_LeftArrow: return "←" case kVK_RightArrow: return "→" @@ -357,7 +357,7 @@ public extension AppShortcutOverride { } } - static func keyEquivalent(for code: UInt16) -> KeyEquivalent { + public static func keyEquivalent(for code: UInt16) -> KeyEquivalent { switch Int(code) { case kVK_LeftArrow: return .leftArrow case kVK_RightArrow: return .rightArrow diff --git a/SupacodeSettingsShared/App/AppShortcuts.swift b/SupacodeSettingsShared/App/AppShortcuts.swift index 46d45ec38..2b45b03a0 100644 --- a/SupacodeSettingsShared/App/AppShortcuts.swift +++ b/SupacodeSettingsShared/App/AppShortcuts.swift @@ -342,7 +342,9 @@ public enum AppShortcuts { AppShortcutGroup(category: .worktreeSelection, shortcuts: worktreeSelection), AppShortcutGroup( category: .actions, - shortcuts: [openWorktree, revealInFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript] + shortcuts: [ + openWorktree, revealInFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript, + ] ), ] @@ -407,9 +409,9 @@ public enum AppShortcuts { // MARK: - View modifier. -public extension View { +extension View { @ViewBuilder - func appKeyboardShortcut(_ shortcut: AppShortcut?) -> some View { + public func appKeyboardShortcut(_ shortcut: AppShortcut?) -> some View { if let shortcut { self.keyboardShortcut(shortcut.keyEquivalent, modifiers: shortcut.modifiers) } else { diff --git a/SupacodeSettingsShared/App/KeyboardShortcut+Display.swift b/SupacodeSettingsShared/App/KeyboardShortcut+Display.swift index 84dea74c3..37098e2ed 100644 --- a/SupacodeSettingsShared/App/KeyboardShortcut+Display.swift +++ b/SupacodeSettingsShared/App/KeyboardShortcut+Display.swift @@ -1,7 +1,7 @@ import SwiftUI -public extension KeyboardShortcut { - var displaySymbols: [String] { +extension KeyboardShortcut { + public var displaySymbols: [String] { var parts: [String] = [] if modifiers.contains(.command) { parts.append("⌘") } if modifiers.contains(.shift) { parts.append("⇧") } @@ -11,13 +11,13 @@ public extension KeyboardShortcut { return parts } - var display: String { + public var display: String { displaySymbols.joined() } } -public extension KeyEquivalent { - var display: String { +extension KeyEquivalent { + public var display: String { switch self { case .delete: "⌫" case .return: "↩" diff --git a/SupacodeSettingsShared/BusinessLogic/ClaudeHookSettings.swift b/SupacodeSettingsShared/BusinessLogic/ClaudeHookSettings.swift index 80fca7ea2..fb4d368f1 100644 --- a/SupacodeSettingsShared/BusinessLogic/ClaudeHookSettings.swift +++ b/SupacodeSettingsShared/BusinessLogic/ClaudeHookSettings.swift @@ -32,7 +32,7 @@ private nonisolated struct ClaudeProgressPayload: Encodable { "UserPromptSubmit": [ .init(hooks: [ .init(command: ClaudeHookSettings.busyOn, timeout: 10) - ]), + ]) ], "Stop": [ .init(hooks: [.init(command: ClaudeHookSettings.busyOff, timeout: 10)]) diff --git a/SupacodeSettingsShared/BusinessLogic/CodexHookSettings.swift b/SupacodeSettingsShared/BusinessLogic/CodexHookSettings.swift index 95b06a6c1..5f56ccb08 100644 --- a/SupacodeSettingsShared/BusinessLogic/CodexHookSettings.swift +++ b/SupacodeSettingsShared/BusinessLogic/CodexHookSettings.swift @@ -33,7 +33,7 @@ private nonisolated struct CodexProgressPayload: Encodable { "UserPromptSubmit": [ .init(hooks: [ .init(command: CodexHookSettings.busyOn, timeout: 10) - ]), + ]) ], "Stop": [ .init(hooks: [.init(command: CodexHookSettings.busyOff, timeout: 10)]) @@ -48,6 +48,6 @@ private nonisolated struct CodexNotificationPayload: Encodable { let hooks: [String: [AgentHookGroup]] = [ "Stop": [ .init(hooks: [.init(command: CodexHookSettings.notify, timeout: 10)]) - ], + ] ] } diff --git a/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift b/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift index 3833c4644..874e50f64 100644 --- a/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift +++ b/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift @@ -94,15 +94,14 @@ public nonisolated struct PinnedWorktreeIDsKey: SharedKey { continuation.resume() } } - -public nonisolated extension SharedReaderKey where Self == RepositoryRootsKey.Default { - static var repositoryRoots: Self { +nonisolated extension SharedReaderKey where Self == RepositoryRootsKey.Default { + public static var repositoryRoots: Self { Self[RepositoryRootsKey(), default: []] } } -public nonisolated extension SharedReaderKey where Self == PinnedWorktreeIDsKey.Default { - static var pinnedWorktreeIDs: Self { +nonisolated extension SharedReaderKey where Self == PinnedWorktreeIDsKey.Default { + public static var pinnedWorktreeIDs: Self { Self[PinnedWorktreeIDsKey(), default: []] } } diff --git a/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift b/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift index 020e61aa0..6fadb94e2 100644 --- a/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift +++ b/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift @@ -87,9 +87,8 @@ public nonisolated struct RepositorySettingsKey: SharedKey { continuation.resume() } } - -public nonisolated extension SharedReaderKey where Self == RepositorySettingsKey.Default { - static func repositorySettings(_ rootURL: URL) -> Self { +nonisolated extension SharedReaderKey where Self == RepositorySettingsKey.Default { + public static func repositorySettings(_ rootURL: URL) -> Self { Self[RepositorySettingsKey(rootURL: rootURL), default: .default] } } diff --git a/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift b/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift index c06737d13..81c111c7a 100644 --- a/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift +++ b/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift @@ -36,20 +36,20 @@ public nonisolated enum SettingsFileURLKey: DependencyKey { public static var testValue: URL { SupacodePaths.settingsURL } } -public extension DependencyValues { - nonisolated var settingsFileStorage: SettingsFileStorage { +extension DependencyValues { + public nonisolated var settingsFileStorage: SettingsFileStorage { get { self[SettingsFileStorageKey.self] } set { self[SettingsFileStorageKey.self] = newValue } } - nonisolated var settingsFileURL: URL { + public nonisolated var settingsFileURL: URL { get { self[SettingsFileURLKey.self] } set { self[SettingsFileURLKey.self] = newValue } } } -public extension SettingsFileStorage { - nonisolated static func inMemory() -> SettingsFileStorage { +extension SettingsFileStorage { + public nonisolated static func inMemory() -> SettingsFileStorage { let storage = InMemorySettingsFileStorage() return SettingsFileStorage( load: { try storage.load($0) }, @@ -154,9 +154,8 @@ public nonisolated struct SettingsFileKey: SharedKey { return encoder } } - -public nonisolated extension SharedReaderKey where Self == SettingsFileKey.Default { - static var settingsFile: Self { +nonisolated extension SharedReaderKey where Self == SettingsFileKey.Default { + public static var settingsFile: Self { Self[SettingsFileKey(), default: .default] } } diff --git a/SupacodeSettingsShared/Clients/Analytics/AnalyticsClient.swift b/SupacodeSettingsShared/Clients/Analytics/AnalyticsClient.swift index 8f2a7178a..60d6cdf89 100644 --- a/SupacodeSettingsShared/Clients/Analytics/AnalyticsClient.swift +++ b/SupacodeSettingsShared/Clients/Analytics/AnalyticsClient.swift @@ -39,8 +39,8 @@ extension AnalyticsClient: DependencyKey { ) } -public extension DependencyValues { - var analyticsClient: AnalyticsClient { +extension DependencyValues { + public var analyticsClient: AnalyticsClient { get { self[AnalyticsClient.self] } set { self[AnalyticsClient.self] = newValue } } @@ -50,8 +50,8 @@ private struct AnalyticsClientKey: EnvironmentKey { static let defaultValue = AnalyticsClient.liveValue } -public extension EnvironmentValues { - var analyticsClient: AnalyticsClient { +extension EnvironmentValues { + public var analyticsClient: AnalyticsClient { get { self[AnalyticsClientKey.self] } set { self[AnalyticsClientKey.self] = newValue } } diff --git a/SupacodeSettingsShared/Clients/CodingAgents/ClaudeSettingsClient.swift b/SupacodeSettingsShared/Clients/CodingAgents/ClaudeSettingsClient.swift index 6024f399d..6e111c3cd 100644 --- a/SupacodeSettingsShared/Clients/CodingAgents/ClaudeSettingsClient.swift +++ b/SupacodeSettingsShared/Clients/CodingAgents/ClaudeSettingsClient.swift @@ -50,8 +50,8 @@ extension ClaudeSettingsClient: DependencyKey { ) } -public extension DependencyValues { - var claudeSettingsClient: ClaudeSettingsClient { +extension DependencyValues { + public var claudeSettingsClient: ClaudeSettingsClient { get { self[ClaudeSettingsClient.self] } set { self[ClaudeSettingsClient.self] = newValue } } diff --git a/SupacodeSettingsShared/Clients/CodingAgents/CodexSettingsClient.swift b/SupacodeSettingsShared/Clients/CodingAgents/CodexSettingsClient.swift index 701664b89..4348b7ca3 100644 --- a/SupacodeSettingsShared/Clients/CodingAgents/CodexSettingsClient.swift +++ b/SupacodeSettingsShared/Clients/CodingAgents/CodexSettingsClient.swift @@ -50,8 +50,8 @@ extension CodexSettingsClient: DependencyKey { ) } -public extension DependencyValues { - var codexSettingsClient: CodexSettingsClient { +extension DependencyValues { + public var codexSettingsClient: CodexSettingsClient { get { self[CodexSettingsClient.self] } set { self[CodexSettingsClient.self] = newValue } } diff --git a/SupacodeSettingsShared/Clients/Notifications/SystemNotificationClient.swift b/SupacodeSettingsShared/Clients/Notifications/SystemNotificationClient.swift index 2d6252970..9c83c6ca5 100644 --- a/SupacodeSettingsShared/Clients/Notifications/SystemNotificationClient.swift +++ b/SupacodeSettingsShared/Clients/Notifications/SystemNotificationClient.swift @@ -119,8 +119,8 @@ extension SystemNotificationClient: DependencyKey { ) } -public extension DependencyValues { - var systemNotificationClient: SystemNotificationClient { +extension DependencyValues { + public var systemNotificationClient: SystemNotificationClient { get { self[SystemNotificationClient.self] } set { self[SystemNotificationClient.self] = newValue } } diff --git a/SupacodeSettingsShared/Clients/Settings/ArchivedWorktreeDatesClient.swift b/SupacodeSettingsShared/Clients/Settings/ArchivedWorktreeDatesClient.swift index c980b1846..586ddf09f 100644 --- a/SupacodeSettingsShared/Clients/Settings/ArchivedWorktreeDatesClient.swift +++ b/SupacodeSettingsShared/Clients/Settings/ArchivedWorktreeDatesClient.swift @@ -55,8 +55,8 @@ extension ArchivedWorktreeDatesClient: DependencyKey { ) } -public extension DependencyValues { - var archivedWorktreeDatesClient: ArchivedWorktreeDatesClient { +extension DependencyValues { + public var archivedWorktreeDatesClient: ArchivedWorktreeDatesClient { get { self[ArchivedWorktreeDatesClient.self] } set { self[ArchivedWorktreeDatesClient.self] = newValue } } diff --git a/SupacodeSettingsShared/Clients/Settings/CLIInstallerClient.swift b/SupacodeSettingsShared/Clients/Settings/CLIInstallerClient.swift index 0e9077427..caae23d7e 100644 --- a/SupacodeSettingsShared/Clients/Settings/CLIInstallerClient.swift +++ b/SupacodeSettingsShared/Clients/Settings/CLIInstallerClient.swift @@ -36,8 +36,8 @@ extension CLIInstallerClient: DependencyKey { ) } -public extension DependencyValues { - var cliInstallerClient: CLIInstallerClient { +extension DependencyValues { + public var cliInstallerClient: CLIInstallerClient { get { self[CLIInstallerClient.self] } set { self[CLIInstallerClient.self] = newValue } } diff --git a/SupacodeSettingsShared/Clients/Settings/CLISkillClient.swift b/SupacodeSettingsShared/Clients/Settings/CLISkillClient.swift index 326307c74..6c5f57355 100644 --- a/SupacodeSettingsShared/Clients/Settings/CLISkillClient.swift +++ b/SupacodeSettingsShared/Clients/Settings/CLISkillClient.swift @@ -30,8 +30,8 @@ extension CLISkillClient: DependencyKey { ) } -public extension DependencyValues { - var cliSkillClient: CLISkillClient { +extension DependencyValues { + public var cliSkillClient: CLISkillClient { get { self[CLISkillClient.self] } set { self[CLISkillClient.self] = newValue } } diff --git a/SupacodeSettingsShared/Clients/Settings/RepositorySettingsGitClient.swift b/SupacodeSettingsShared/Clients/Settings/RepositorySettingsGitClient.swift index 7def9bb5a..e09cb1f6b 100644 --- a/SupacodeSettingsShared/Clients/Settings/RepositorySettingsGitClient.swift +++ b/SupacodeSettingsShared/Clients/Settings/RepositorySettingsGitClient.swift @@ -31,8 +31,8 @@ extension RepositorySettingsGitClient: DependencyKey { ) } -public extension DependencyValues { - var repositorySettingsGitClient: RepositorySettingsGitClient { +extension DependencyValues { + public var repositorySettingsGitClient: RepositorySettingsGitClient { get { self[RepositorySettingsGitClient.self] } set { self[RepositorySettingsGitClient.self] = newValue } } diff --git a/SupacodeSettingsShared/Clients/Shell/ShellClient.swift b/SupacodeSettingsShared/Clients/Shell/ShellClient.swift index 7d7c725f5..8a3cbcda6 100644 --- a/SupacodeSettingsShared/Clients/Shell/ShellClient.swift +++ b/SupacodeSettingsShared/Clients/Shell/ShellClient.swift @@ -139,8 +139,8 @@ extension ShellClient: DependencyKey { ) } -public extension DependencyValues { - var shellClient: ShellClient { +extension DependencyValues { + public var shellClient: ShellClient { get { self[ShellClient.self] } set { self[ShellClient.self] = newValue } } diff --git a/SupacodeSettingsShared/Models/RepositorySettings.swift b/SupacodeSettingsShared/Models/RepositorySettings.swift index 13d83154f..6e83e0af0 100644 --- a/SupacodeSettingsShared/Models/RepositorySettings.swift +++ b/SupacodeSettingsShared/Models/RepositorySettings.swift @@ -4,7 +4,11 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { public var setupScript: String public var archiveScript: String public var deleteScript: String - public var runScript: String + /// Legacy field kept for backward-compatible JSON serialization. + /// New code should use `scripts` instead. On encode, this is + /// derived from the first `.run`-kind script's command. + public private(set) var runScript: String + public var scripts: [ScriptDefinition] public var openActionID: String public var worktreeBaseRef: String? public var worktreeBaseDirectoryPath: String? @@ -17,6 +21,7 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { case archiveScript case deleteScript case runScript + case scripts case openActionID case worktreeBaseRef case worktreeBaseDirectoryPath @@ -30,12 +35,13 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { archiveScript: "", deleteScript: "", runScript: "", + scripts: [], openActionID: OpenWorktreeAction.automaticSettingsID, worktreeBaseRef: nil, worktreeBaseDirectoryPath: nil, copyIgnoredOnWorktreeCreate: nil, copyUntrackedOnWorktreeCreate: nil, - pullRequestMergeStrategy: nil + pullRequestMergeStrategy: nil, ) public init( @@ -43,6 +49,7 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { archiveScript: String, deleteScript: String, runScript: String, + scripts: [ScriptDefinition] = [], openActionID: String, worktreeBaseRef: String?, worktreeBaseDirectoryPath: String? = nil, @@ -54,6 +61,7 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { self.archiveScript = archiveScript self.deleteScript = deleteScript self.runScript = runScript + self.scripts = scripts self.openActionID = openActionID self.worktreeBaseRef = worktreeBaseRef self.worktreeBaseDirectoryPath = worktreeBaseDirectoryPath @@ -76,6 +84,18 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { runScript = try container.decodeIfPresent(String.self, forKey: .runScript) ?? Self.default.runScript + // Migrate legacy `runScript` into the new `scripts` array when + // the `scripts` key is absent from persisted JSON. + // Decode element-by-element so a single unknown `ScriptKind` + // only drops that entry, not the entire array. + let decodedScripts = Self.decodeScriptsLossily(from: container) + if let decodedScripts { + scripts = decodedScripts + } else if !runScript.isEmpty { + scripts = [ScriptDefinition(kind: .run, command: runScript)] + } else { + scripts = Self.default.scripts + } openActionID = try container.decodeIfPresent(String.self, forKey: .openActionID) ?? Self.default.openActionID @@ -93,4 +113,51 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { try container.decodeIfPresent(PullRequestMergeStrategy.self, forKey: .pullRequestMergeStrategy) ?? Self.default.pullRequestMergeStrategy } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(setupScript, forKey: .setupScript) + try container.encode(archiveScript, forKey: .archiveScript) + try container.encode(deleteScript, forKey: .deleteScript) + // Derive `runScript` from the first `.run`-kind script's command + // so older clients can still read the value. + // Fall back to empty string (not the legacy `runScript` property) + // so removing all `.run` scripts correctly signals removal to + // older clients instead of leaking the stale legacy value. + let derivedRunScript = scripts.first(where: { $0.kind == .run })?.command ?? "" + try container.encode(derivedRunScript, forKey: .runScript) + try container.encode(scripts, forKey: .scripts) + try container.encode(openActionID, forKey: .openActionID) + try container.encodeIfPresent(worktreeBaseRef, forKey: .worktreeBaseRef) + try container.encodeIfPresent(worktreeBaseDirectoryPath, forKey: .worktreeBaseDirectoryPath) + try container.encodeIfPresent(copyIgnoredOnWorktreeCreate, forKey: .copyIgnoredOnWorktreeCreate) + try container.encodeIfPresent(copyUntrackedOnWorktreeCreate, forKey: .copyUntrackedOnWorktreeCreate) + try container.encodeIfPresent(pullRequestMergeStrategy, forKey: .pullRequestMergeStrategy) + } + + /// Decodes the `scripts` array element-by-element, silently + /// skipping entries that fail (e.g. unknown `ScriptKind`). + /// Returns `nil` when the key is absent (legacy JSON), or `[]` + /// when the key is present but corrupted (e.g. `null`). + private static func decodeScriptsLossily( + from container: KeyedDecodingContainer + ) -> [ScriptDefinition]? { + guard container.contains(.scripts) else { return nil } + guard let wrappers = try? container.decode([Lossy].self, forKey: .scripts) else { + // Key exists but value is not a valid array (e.g. null or + // wrong type). Return empty rather than triggering legacy + // migration which would overwrite with stale data. + return [] + } + return wrappers.compactMap { $0.value } + } +} + +/// Wrapper that always succeeds at the container level, +/// capturing decode failures as `nil` instead of throwing. +private nonisolated struct Lossy: Decodable, Sendable { + nonisolated let value: T? + nonisolated init(from decoder: Decoder) throws { + value = try? T(from: decoder) + } } diff --git a/SupacodeSettingsShared/Models/ScriptDefinition.swift b/SupacodeSettingsShared/Models/ScriptDefinition.swift new file mode 100644 index 000000000..ede212e92 --- /dev/null +++ b/SupacodeSettingsShared/Models/ScriptDefinition.swift @@ -0,0 +1,64 @@ +import Foundation + +/// A user-configured script that can be run on demand from the +/// toolbar, command palette, or keyboard shortcut. Each repository +/// stores an ordered array of these in `RepositorySettings.scripts`. +public nonisolated struct ScriptDefinition: Identifiable, Codable, Equatable, Hashable, Sendable { + public var id: UUID + public var kind: ScriptKind + public var name: String + public var command: String + + /// Per-instance overrides — only meaningful for `.custom` kinds. + /// Predefined kinds always resolve to the kind default. + public var systemImage: String? + public var tintColor: TerminalTabTintColor? + + /// Display name for toolbar labels: predefined types show their + /// kind name ("Run", "Test"), custom types show user-defined name. + public nonisolated var displayName: String { + kind == .custom ? name : kind.defaultName + } + + /// Resolved SF Symbol name: predefined types always use the kind + /// default so future icon changes propagate automatically. + public nonisolated var resolvedSystemImage: String { + kind == .custom ? (systemImage ?? kind.defaultSystemImage) : kind.defaultSystemImage + } + + /// Resolved tint color: predefined types always use the kind + /// default so future color changes propagate automatically. + public nonisolated var resolvedTintColor: TerminalTabTintColor { + kind == .custom ? (tintColor ?? kind.defaultTintColor) : kind.defaultTintColor + } + + public nonisolated init( + id: UUID = UUID(), + kind: ScriptKind, + name: String? = nil, + systemImage: String? = nil, + tintColor: TerminalTabTintColor? = nil, + command: String = "" + ) { + self.id = id + self.kind = kind + self.name = name ?? kind.defaultName + self.systemImage = systemImage + self.tintColor = tintColor + self.command = command + } +} + +// MARK: - Collection helpers + +extension [ScriptDefinition] { + /// The first `.run`-kind script — the primary toolbar action. + public var primaryScript: ScriptDefinition? { + first { $0.kind == .run } + } + + /// Whether any `.run`-kind script is currently running. + public func hasRunningRunScript(in runningIDs: Set) -> Bool { + contains { $0.kind == .run && runningIDs.contains($0.id) } + } +} diff --git a/SupacodeSettingsShared/Models/ScriptKind.swift b/SupacodeSettingsShared/Models/ScriptKind.swift new file mode 100644 index 000000000..ad40d4919 --- /dev/null +++ b/SupacodeSettingsShared/Models/ScriptKind.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Identifies the semantic category of a user-defined script. +/// Predefined kinds carry default icon, color, and name; `.custom` +/// requires explicit values stored on the owning `ScriptDefinition`. +public enum ScriptKind: String, Codable, CaseIterable, Hashable, Sendable { + case run + case test + case deploy + case lint + case format + case custom + + /// Default display name shown in UI when the user hasn't provided one. + public nonisolated var defaultName: String { + switch self { + case .run: "Run" + case .test: "Test" + case .deploy: "Deploy" + case .lint: "Lint" + case .format: "Format" + case .custom: "Custom" + } + } + + /// Default SF Symbol name for the script kind. + public nonisolated var defaultSystemImage: String { + switch self { + case .run: "play" + case .test: "play.diamond" + case .deploy: "arrowshape.turn.up.forward" + case .lint: "exclamationmark.triangle" + case .format: "circle.dotted.circle" + case .custom: "text.alignleft" + } + } + + /// Default tab tint color for the script kind. + public nonisolated var defaultTintColor: TerminalTabTintColor { + switch self { + case .run: .green + case .test: .yellow + case .deploy: .red + case .lint: .blue + case .format: .teal + case .custom: .purple + } + } +} diff --git a/SupacodeSettingsShared/Models/TerminalTabTintColor.swift b/SupacodeSettingsShared/Models/TerminalTabTintColor.swift new file mode 100644 index 000000000..5ea54c436 --- /dev/null +++ b/SupacodeSettingsShared/Models/TerminalTabTintColor.swift @@ -0,0 +1,40 @@ +import AppKit +import SwiftUI + +/// Color token for terminal tab tint indicators, used in place of +/// `Color` so that related types can remain `Equatable` and `Sendable`. +public enum TerminalTabTintColor: String, Codable, CaseIterable, Hashable, Sendable { + case green + case orange + case red + case blue + case purple + case yellow + case teal + + /// Resolved SwiftUI color for rendering. + public var color: Color { + switch self { + case .green: .green + case .orange: .orange + case .red: .red + case .blue: .blue + case .purple: .purple + case .yellow: .yellow + case .teal: .teal + } + } + + /// Resolved AppKit color for use in NSImage tinting. + public var nsColor: NSColor { + switch self { + case .green: .systemGreen + case .orange: .systemOrange + case .red: .systemRed + case .blue: .systemBlue + case .purple: .systemPurple + case .yellow: .systemYellow + case .teal: .systemTeal + } + } +} diff --git a/SupacodeSettingsShared/Support/TintedSymbol.swift b/SupacodeSettingsShared/Support/TintedSymbol.swift new file mode 100644 index 000000000..5aea29df9 --- /dev/null +++ b/SupacodeSettingsShared/Support/TintedSymbol.swift @@ -0,0 +1,36 @@ +import AppKit +import SwiftUI + +extension Image { + /// Creates a tinted SF Symbol image suitable for AppKit menu + /// rendering. Works around the macOS quirk where `Menu` items + /// strip SwiftUI color modifiers from SF Symbol icons. + /// + /// Automatically resolves the filled variant of the symbol + /// (appending `.fill` to the name) when one exists, falling + /// back to the original name. This means callers don't need + /// to track both outline and filled symbol names separately. + public static func tintedSymbol(_ name: String, color: NSColor) -> Image { + let resolvedName = filledSymbolName(for: name) + let config = NSImage.SymbolConfiguration(paletteColors: [color]) + guard + let base = NSImage(systemSymbolName: resolvedName, accessibilityDescription: nil), + let tinted = base.withSymbolConfiguration(config) + else { + return Image(systemName: resolvedName) + } + tinted.isTemplate = false + return Image(nsImage: tinted) + } + + /// Returns the `.fill` variant of a symbol name if it exists, + /// otherwise returns the original name unchanged. + private static func filledSymbolName(for name: String) -> String { + guard !name.hasSuffix(".fill") else { return name } + let filled = "\(name).fill" + guard NSImage(systemSymbolName: filled, accessibilityDescription: nil) != nil else { + return name + } + return filled + } +} diff --git a/SupacodeSettingsShared/Support/VerticallyCenteredLabelStyle.swift b/SupacodeSettingsShared/Support/VerticallyCenteredLabelStyle.swift new file mode 100644 index 000000000..0a37f3aec --- /dev/null +++ b/SupacodeSettingsShared/Support/VerticallyCenteredLabelStyle.swift @@ -0,0 +1,18 @@ +import SwiftUI + +/// A label style that arranges the icon and title +/// horizontally with vertical center alignment. +public struct VerticallyCenteredLabelStyle: LabelStyle { + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 6) { + configuration.icon + configuration.title + } + } +} + +extension LabelStyle where Self == VerticallyCenteredLabelStyle { + public static var verticallyCentered: VerticallyCenteredLabelStyle { .init() } +} diff --git a/supacode.json b/supacode.json index 243d8d32c..a08c80c4b 100644 --- a/supacode.json +++ b/supacode.json @@ -2,8 +2,35 @@ "archiveScript" : "", "copyIgnoredOnWorktreeCreate" : true, "copyUntrackedOnWorktreeCreate" : true, + "deleteScript" : "", "openActionID" : "xcode", "pullRequestMergeStrategy" : "merge", "runScript" : "make run-app", - "setupScript" : "git submodule update -f --depth=1 --init --no-fetch -j 8 --progress\ncodex" + "scripts" : [ + { + "command" : "make run-app", + "id" : "B90F7467-8638-4FB6-9CFC-450207596267", + "kind" : "run", + "name" : "Run" + }, + { + "command" : "make test", + "id" : "D477CB4A-8BF5-46E7-B192-E22193DC0705", + "kind" : "test", + "name" : "Test" + }, + { + "command" : "make check", + "id" : "19B9B006-EEF2-4C96-9CC6-7DCCF9F3C09D", + "kind" : "lint", + "name" : "Lint" + }, + { + "command" : "make format", + "id" : "914F90DE-B782-4A76-9845-8A1A45803833", + "kind" : "format", + "name" : "Format" + } + ], + "setupScript" : "git submodule update --recursive" } \ No newline at end of file diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 3d315eacd..3ecd6d55b 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -6,6 +6,7 @@ // import ComposableArchitecture +import SupacodeSettingsShared import SwiftUI import UniformTypeIdentifiers @@ -24,14 +25,6 @@ struct ContentView: View { } var body: some View { - let isRunScriptPromptPresented = Binding( - get: { store.isRunScriptPromptPresented }, - set: { store.send(.runScriptPromptPresented($0)) } - ) - let runScriptDraft = Binding( - get: { store.runScriptDraft }, - set: { store.send(.runScriptDraftChanged($0)) } - ) NavigationSplitView(columnVisibility: $leftSidebarVisibility) { SidebarView(store: repositoriesStore, terminalManager: terminalManager) .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 320) @@ -40,6 +33,7 @@ struct ContentView: View { } .navigationSplitViewStyle(.automatic) .disabled(!store.repositories.isInitialLoadComplete) + .environment(\.scriptsByID, Dictionary(uniqueKeysWithValues: store.scripts.map { ($0.id, $0) })) .environment(\.surfaceBackgroundOpacity, terminalManager.surfaceBackgroundOpacity()) .onChange(of: scenePhase) { _, newValue in store.send(.scenePhaseChanged(newValue)) @@ -75,17 +69,6 @@ struct ContentView: View { ) { promptStore in WorktreeCreationPromptView(store: promptStore) } - .sheet(isPresented: isRunScriptPromptPresented) { - RunScriptPromptView( - script: runScriptDraft, - onCancel: { - store.send(.runScriptPromptPresented(false)) - }, - onSaveAndRun: { - store.send(.saveRunScriptAndRun) - } - ) - } .focusedSceneValue(\.toggleLeftSidebarAction, toggleLeftSidebar) .focusedSceneValue(\.revealInSidebarAction, revealInSidebarAction) .overlay { @@ -93,7 +76,9 @@ struct ContentView: View { store: store.scope(state: \.commandPalette, action: \.commandPalette), items: CommandPaletteFeature.commandPaletteItems( from: store.repositories, - ghosttyCommands: ghosttyShortcuts.commandPaletteEntries + ghosttyCommands: ghosttyShortcuts.commandPaletteEntries, + scripts: store.scripts, + runningScriptIDs: store.runningScriptIDs ) ) } @@ -120,6 +105,18 @@ struct ContentView: View { } +private struct ScriptsByIDEnvironmentKey: EnvironmentKey { + static let defaultValue: [UUID: ScriptDefinition] = [:] +} + +extension EnvironmentValues { + /// Pre-computed lookup for sidebar row color resolution. + var scriptsByID: [UUID: ScriptDefinition] { + get { self[ScriptsByIDEnvironmentKey.self] } + set { self[ScriptsByIDEnvironmentKey.self] = newValue } + } +} + private struct SurfaceBackgroundOpacityKey: EnvironmentKey { static let defaultValue: Double = 1 } @@ -152,57 +149,3 @@ extension EnvironmentValues { } } } - -private struct RunScriptPromptView: View { - @Binding var script: String - let onCancel: () -> Void - let onSaveAndRun: () -> Void - - private var canSave: Bool { - !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text("Run") - .font(.title3) - Text("Enter a command to run in this worktree. It will be saved to repository settings.") - .foregroundStyle(.secondary) - } - - ZStack(alignment: .topLeading) { - PlainTextEditor( - text: $script, - isMonospaced: true - ) - .frame(minHeight: 160) - if script.isEmpty { - Text("npm run dev") - .foregroundStyle(.secondary) - .padding(.leading, 6) - .font(.body.monospaced()) - .allowsHitTesting(false) - } - } - - HStack { - Spacer() - Button("Cancel") { - onCancel() - } - .keyboardShortcut(.cancelAction) - .help("Cancel (Esc)") - - Button("Save and Run") { - onSaveAndRun() - } - .keyboardShortcut(.defaultAction) - .help("Save and Run (↩)") - .disabled(!canSave) - } - } - .padding(20) - .frame(minWidth: 520) - } -} diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 6d302dc64..1091ac165 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -13,6 +13,7 @@ struct TerminalClient { case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool, id: UUID? = nil) case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) case stopRunScript(Worktree) + case stopScript(Worktree, definitionID: UUID) case runBlockingScript(Worktree, kind: BlockingScriptKind, script: String) case closeFocusedTab(Worktree) case closeFocusedSurface(Worktree) diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 938cfc93b..0c0d5b4e1 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -101,7 +101,7 @@ struct WorktreeCommands: Commands { .disabled(deleteWorktreeAction == nil) Divider() // Scripts. - Button("Run Script", systemImage: "play") { + Button("Run Script", systemImage: ScriptKind.run.defaultSystemImage) { runScriptAction?() } .appKeyboardShortcut(run) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 7a9941eba..de0c23cf1 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -22,9 +22,7 @@ struct AppFeature { var updates = UpdatesFeature.State() var commandPalette = CommandPaletteFeature.State() var openActionSelection: OpenWorktreeAction = .finder - var selectedRunScript: String = "" - var runScriptDraft: String = "" - var isRunScriptPromptPresented = false + var scripts: [ScriptDefinition] = [] var notificationIndicatorCount: Int = 0 var lastKnownSystemNotificationsEnabled: Bool var pendingDeeplinks: [Deeplink] = [] @@ -40,6 +38,22 @@ struct AppFeature { self.settings = settings lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled } + + /// The script that the primary toolbar button should run. + var primaryScript: ScriptDefinition? { + scripts.primaryScript + } + + /// Running script IDs for the currently selected worktree. + var runningScriptIDs: Set { + guard let worktreeID = repositories.selectedWorktreeID else { return [] } + return repositories.runningScriptsByWorktreeID[worktreeID] ?? [] + } + + /// Whether any `.run`-kind script is currently running in the selected worktree. + var hasRunningRunScript: Bool { + scripts.hasRunningRunScript(in: runningScriptIDs) + } } enum Action { @@ -58,10 +72,9 @@ struct AppFeature { case requestQuit case newTerminal case runScript - case runScriptDraftChanged(String) - case runScriptPromptPresented(Bool) - case saveRunScriptAndRun - case stopRunScript + case runNamedScript(ScriptDefinition) + case stopScript(ScriptDefinition) + case stopRunScripts case closeTab case closeSurface case startSearch @@ -142,9 +155,7 @@ struct AppFeature { let repositoryPersistence = repositoryPersistence guard let worktree else { state.openActionSelection = .finder - state.selectedRunScript = "" - state.runScriptDraft = "" - state.isRunScriptPromptPresented = false + state.scripts = [] var effects: [Effect] = [ .run { _ in await terminalClient.send(.setSelectedWorktreeID(nil)) @@ -165,8 +176,6 @@ struct AppFeature { } let rootURL = worktree.repositoryRootURL let worktreeID = worktree.id - state.runScriptDraft = "" - state.isRunScriptPromptPresented = false @Shared(.repositorySettings(rootURL)) var repositorySettings let settings = repositorySettings return .merge( @@ -202,8 +211,12 @@ struct AppFeature { repositories.flatMap { $0.worktrees.map(\.id) } .filter { !archivedIDs.contains($0) || deleteScriptIDs.contains($0) } ) - state.repositories.runScriptWorktreeIDs.formIntersection(ids) - let recencyIDs = CommandPaletteFeature.recencyRetentionIDs(from: repositories) + state.repositories.runningScriptsByWorktreeID = state.repositories.runningScriptsByWorktreeID + .filter { ids.contains($0.key) } + let recencyIDs = CommandPaletteFeature.recencyRetentionIDs( + from: repositories, + scripts: state.scripts + ) let worktrees = state.repositories.worktreesForInfoWatcher() var effects: [Effect] = [ .send( @@ -412,59 +425,44 @@ struct AppFeature { } case .runScript: - guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { - return .none - } - let trimmed = state.selectedRunScript.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - if state.isRunScriptPromptPresented { + // Find the selected or primary script and run it. + guard let definition = state.primaryScript else { + // No scripts configured — open repository scripts settings. + guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { return .none } - state.runScriptDraft = state.selectedRunScript - state.isRunScriptPromptPresented = true + let repositoryID = worktree.repositoryRootURL.path(percentEncoded: false) + return .send(.settings(.setSelection(.repositoryScripts(repositoryID)))) + } + return .send(.runNamedScript(definition)) + + case .runNamedScript(let definition): + guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { return .none } - analyticsClient.capture("script_run", nil) - state.repositories.runScriptWorktreeIDs.insert(worktree.id) - let script = state.selectedRunScript + // Prevent running the same script twice. + guard !state.runningScriptIDs.contains(definition.id) else { return .none } + let trimmed = definition.command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .none } + analyticsClient.capture("script_run", ["kind": definition.kind.rawValue]) + var ids = state.repositories.runningScriptsByWorktreeID[worktree.id] ?? [] + ids.insert(definition.id) + state.repositories.runningScriptsByWorktreeID[worktree.id] = ids return .run { _ in - await terminalClient.send(.runBlockingScript(worktree, kind: .run, script: script)) - } - - case .runScriptDraftChanged(let script): - state.runScriptDraft = script - return .none - - case .runScriptPromptPresented(let isPresented): - state.isRunScriptPromptPresented = isPresented - if !isPresented { - state.runScriptDraft = "" + await terminalClient.send( + .runBlockingScript(worktree, kind: .script(definition), script: definition.command) + ) } - return .none - case .saveRunScriptAndRun: + case .stopScript(let definition): guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { - state.isRunScriptPromptPresented = false - state.runScriptDraft = "" - return .none - } - let script = state.runScriptDraft - let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return .none } - let rootURL = worktree.repositoryRootURL - @Shared(.repositorySettings(rootURL)) var repositorySettings - $repositorySettings.withLock { $0.runScript = script } - if state.settings.repositorySettings?.rootURL == rootURL { - state.settings.repositorySettings?.settings.runScript = script + return .run { _ in + await terminalClient.send(.stopScript(worktree, definitionID: definition.id)) } - state.selectedRunScript = script - state.isRunScriptPromptPresented = false - state.runScriptDraft = "" - return .send(.runScript) - case .stopRunScript: + case .stopRunScripts: guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { return .none } @@ -551,7 +549,7 @@ struct AppFeature { settings.openActionID, defaultEditorID: normalizedDefaultEditorID ) - state.selectedRunScript = settings.runScript + state.scripts = settings.scripts return .none case .deeplinkReceived(let url, let source, let responseFD): @@ -748,6 +746,19 @@ struct AppFeature { case .commandPalette(.delegate(.openFailingCheckDetails(let worktreeID))): return .send(.repositories(.pullRequestAction(worktreeID, .openFailingCheckDetails))) + case .commandPalette(.delegate(.runScript(let definition))): + return .send(.runNamedScript(definition)) + + case .commandPalette(.delegate(.stopScript(let scriptID, _))): + // If a script was removed from settings while still running, + // it won't appear here. That is intentional — the terminal + // tab stays open and cleans up on natural completion or when + // the user closes the tab manually. + guard let definition = state.scripts.first(where: { $0.id == scriptID }) else { + return .none + } + return .send(.stopScript(definition)) + #if DEBUG case .commandPalette(.delegate(.debugTestToast(let toast))): return .send(.repositories(.showToast(toast))) @@ -797,8 +808,18 @@ struct AppFeature { case .terminalEvent(.blockingScriptCompleted(let worktreeID, let kind, let exitCode, let tabId)): switch kind { - case .run: - return .send(.repositories(.runScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) + case .script(let definition): + return .send( + .repositories( + .scriptCompleted( + worktreeID: worktreeID, + scriptID: definition.id, + kind: kind, + exitCode: exitCode, + tabId: tabId + ) + ) + ) case .archive: return .send(.repositories(.archiveScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) case .delete: @@ -995,7 +1016,7 @@ struct AppFeature { case .run: return .send(.runScript) case .stop: - return .send(.stopRunScript) + return .send(.stopRunScripts) case .archive: guard let repositoryID = resolveRepositoryID(for: worktreeID, label: "archive", state: &state) else { return .none diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index 4216ddb6b..96dbd199d 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -1,3 +1,4 @@ +import Foundation import Sharing import SupacodeSettingsShared @@ -43,6 +44,8 @@ struct CommandPaletteItem: Identifiable, Equatable { case copyCiFailureLogs(Worktree.ID) case rerunFailedJobs(Worktree.ID) case openFailingCheckDetails(Worktree.ID) + case runScript(ScriptDefinition) + case stopScript(UUID, name: String) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) #endif @@ -66,6 +69,8 @@ struct CommandPaletteItem: Identifiable, Equatable { true case .worktreeSelect, .removeWorktree, .archiveWorktree: false + case .runScript, .stopScript: + true #if DEBUG case .debugTestToast: true @@ -92,6 +97,8 @@ struct CommandPaletteItem: Identifiable, Equatable { .removeWorktree, .archiveWorktree: false + case .runScript, .stopScript: + false #if DEBUG case .debugTestToast: false @@ -118,8 +125,11 @@ struct CommandPaletteItem: Identifiable, Equatable { .openFailingCheckDetails, .worktreeSelect, .removeWorktree, - .archiveWorktree: + .archiveWorktree, + .stopScript: nil + case .runScript(let definition): + definition.kind == .run ? AppShortcuts.runScript : nil #if DEBUG case .debugTestToast: nil diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index b44e68277..eef75ca90 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import Foundation import Sharing +import SupacodeSettingsShared @Reducer struct CommandPaletteFeature { @@ -49,6 +50,8 @@ struct CommandPaletteFeature { case copyCiFailureLogs(Worktree.ID) case rerunFailedJobs(Worktree.ID) case openFailingCheckDetails(Worktree.ID) + case runScript(ScriptDefinition) + case stopScript(UUID, name: String) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) #endif @@ -163,7 +166,9 @@ struct CommandPaletteFeature { static func commandPaletteItems( from repositories: RepositoriesFeature.State, - ghosttyCommands: [GhosttyCommand] = [] + ghosttyCommands: [GhosttyCommand] = [], + scripts: [ScriptDefinition] = [], + runningScriptIDs: Set = [] ) -> [CommandPaletteItem] { var items: [CommandPaletteItem] = [ CommandPaletteItem( @@ -205,6 +210,7 @@ struct CommandPaletteFeature { ] if repositories.selectedWorktreeID != nil { items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) + items.append(contentsOf: scriptItems(scripts: scripts, runningScriptIDs: runningScriptIDs)) } if let selectedWorktreeID = repositories.selectedWorktreeID, let repositoryID = repositories.repositoryID(containing: selectedWorktreeID), @@ -239,7 +245,8 @@ struct CommandPaletteFeature { } static func recencyRetentionIDs( - from repositories: IdentifiedArrayOf + from repositories: IdentifiedArrayOf, + scripts: [ScriptDefinition] = [] ) -> [CommandPaletteItem.ID] { var ids = CommandPaletteItemID.globalIDs for repository in repositories { @@ -248,6 +255,10 @@ struct CommandPaletteFeature { ids.append(CommandPaletteItemID.worktreeSelect(worktree.id)) } } + for script in scripts { + ids.append(CommandPaletteItemID.runScript(script.id)) + ids.append(CommandPaletteItemID.stopScript(script.id)) + } return ids } } @@ -333,7 +344,7 @@ private func pullRequestItems( subtitle: pullRequest.title, kind: .openPullRequest(worktreeID), priorityTier: 2 - ), + ) ] if let readyItem = makeReadyItem() { @@ -491,6 +502,14 @@ private enum CommandPaletteItemID { static func pullRequestClose(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { "pr.\(repositoryID).close" } + + static func runScript(_ scriptID: UUID) -> CommandPaletteItem.ID { + "script.\(scriptID).run" + } + + static func stopScript(_ scriptID: UUID) -> CommandPaletteItem.ID { + "script.\(scriptID).stop" + } } private func prioritizeItems( @@ -556,6 +575,10 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette .rerunFailedJobs, .openFailingCheckDetails: return pullRequestDelegateAction(for: kind)! + case .runScript(let definition): + return .runScript(definition) + case .stopScript(let scriptID, let name): + return .stopScript(scriptID, name: name) #if DEBUG case .debugTestToast(let toast): return .debugTestToast(toast) @@ -592,7 +615,9 @@ private func pullRequestDelegateAction( .archiveWorktree, .viewArchivedWorktrees, .refreshWorktrees, - .ghosttyCommand: + .ghosttyCommand, + .runScript, + .stopScript: return nil #if DEBUG case .debugTestToast: @@ -601,6 +626,38 @@ private func pullRequestDelegateAction( } } +private func scriptItems( + scripts: [ScriptDefinition], + runningScriptIDs: Set +) -> [CommandPaletteItem] { + var items: [CommandPaletteItem] = [] + for script in scripts { + let trimmed = script.command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + if runningScriptIDs.contains(script.id) { + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.stopScript(script.id), + title: "Stop: \(script.displayName)", + subtitle: nil, + kind: .stopScript(script.id, name: script.displayName), + priorityTier: 0 + ) + ) + } else { + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.runScript(script.id), + title: "Run: \(script.displayName)", + subtitle: nil, + kind: .runScript(script) + ) + ) + } + } + return items +} + private func ghosttyCommandItems(_ commands: [GhosttyCommand]) -> [CommandPaletteItem] { commands.map { command in let subtitle = command.description.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 7da37690a..8ce380cae 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -1,6 +1,7 @@ import AppKit import ComposableArchitecture import Foundation +import SupacodeSettingsShared import SwiftUI struct CommandPaletteOverlayView: View { @@ -351,6 +352,10 @@ private struct CommandPaletteRowView: View { return "Remove" case .archiveWorktree: return "Archive" + case .runScript: + return "Script" + case .stopScript: + return "Script" #if DEBUG case .debugTestToast: return "Debug" @@ -396,6 +401,10 @@ private struct CommandPaletteRowView: View { return "trash" case .archiveWorktree: return "archivebox" + case .runScript(let definition): + return definition.resolvedSystemImage + case .stopScript: + return "stop.fill" #if DEBUG case .debugTestToast: return "ladybug" @@ -414,6 +423,8 @@ private struct CommandPaletteRowView: View { return true case .worktreeSelect, .removeWorktree, .archiveWorktree: return false + case .runScript, .stopScript: + return true #if DEBUG case .debugTestToast: return true @@ -524,6 +535,10 @@ private struct CommandPaletteRowView: View { base = "Re-run failed jobs" case .openFailingCheckDetails: base = "Open failing check details" + case .runScript(let definition): + base = "Run \(definition.name)" + case .stopScript(_, let name): + base = "Stop \(name)" #if DEBUG case .debugTestToast: base = row.title diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index f3dfc0e47..8538bcad2 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -77,7 +77,7 @@ struct RepositoriesFeature { var pendingWorktrees: [PendingWorktree] = [] var pendingSetupScriptWorktreeIDs: Set = [] var pendingTerminalFocusWorktreeIDs: Set = [] - var runScriptWorktreeIDs: Set = [] + var runningScriptsByWorktreeID: [Worktree.ID: Set] = [:] var archivingWorktreeIDs: Set = [] var deleteScriptWorktreeIDs: Set = [] var deletingWorktreeIDs: Set = [] @@ -200,7 +200,8 @@ struct RepositoriesFeature { ) case consumeSetupScript(Worktree.ID) case consumeTerminalFocus(Worktree.ID) - case runScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?, tabId: TerminalTabID?) + case scriptCompleted( + worktreeID: Worktree.ID, scriptID: UUID, kind: BlockingScriptKind, exitCode: Int?, tabId: TerminalTabID?) case requestArchiveWorktree(Worktree.ID, Repository.ID) case requestArchiveWorktrees([ArchiveWorktreeTarget]) case archiveWorktreeConfirmed(Worktree.ID, Repository.ID) @@ -1393,15 +1394,24 @@ struct RepositoriesFeature { } ) - case .runScriptCompleted(let worktreeID, let exitCode, let tabId): - guard state.runScriptWorktreeIDs.contains(worktreeID) else { - repositoriesLogger.debug("Ignoring runScriptCompleted for \(worktreeID): not in runScriptWorktreeIDs") + case .scriptCompleted(let worktreeID, let scriptID, let kind, let exitCode, let tabId): + guard var ids = state.runningScriptsByWorktreeID[worktreeID], ids.contains(scriptID) else { + repositoriesLogger.debug("Ignoring scriptCompleted for \(worktreeID)/\(scriptID): not tracked") return .none } - state.runScriptWorktreeIDs.remove(worktreeID) + ids.remove(scriptID) + if ids.isEmpty { + state.runningScriptsByWorktreeID.removeValue(forKey: worktreeID) + } else { + state.runningScriptsByWorktreeID[worktreeID] = ids + } guard let exitCode, exitCode != 0 else { return .none } state.alert = blockingScriptFailureAlert( - kind: .run, exitCode: exitCode, worktreeID: worktreeID, tabId: tabId, state: state + kind: kind, + exitCode: exitCode, + worktreeID: worktreeID, + tabId: tabId, + state: state ) return .none @@ -2010,7 +2020,7 @@ struct RepositoriesFeature { var effects: [Effect] = [ .run { _ in await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) - }, + } ] if didUpdateWorktreeOrder { let worktreeOrderByRepository = state.worktreeOrderByRepository @@ -2037,7 +2047,7 @@ struct RepositoriesFeature { var effects: [Effect] = [ .run { _ in await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) - }, + } ] if didUpdateWorktreeOrder { let worktreeOrderByRepository = state.worktreeOrderByRepository @@ -2912,7 +2922,9 @@ struct RepositoriesFeature { let filteredFocusIDs = state.pendingTerminalFocusWorktreeIDs.filter { availableWorktreeIDs.contains($0) } - let filteredRunScriptIDs = state.runScriptWorktreeIDs + let filteredRunningScripts = state.runningScriptsByWorktreeID.filter { + availableWorktreeIDs.contains($0.key) + } let filteredArchivingIDs = state.archivingWorktreeIDs let filteredWorktreeInfo = state.worktreeInfoByID.filter { availableWorktreeIDs.contains($0.key) @@ -2926,7 +2938,8 @@ struct RepositoriesFeature { state.deleteScriptWorktreeIDs = filteredDeleteScriptIDs state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs - state.runScriptWorktreeIDs = filteredRunScriptIDs + state.runningScriptsByWorktreeID = filteredRunningScripts + state.archivingWorktreeIDs = filteredArchivingIDs state.worktreeInfoByID = filteredWorktreeInfo } @@ -2937,6 +2950,7 @@ struct RepositoriesFeature { state.deleteScriptWorktreeIDs = filteredDeleteScriptIDs state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs + state.runningScriptsByWorktreeID = filteredRunningScripts state.archivingWorktreeIDs = filteredArchivingIDs state.worktreeInfoByID = filteredWorktreeInfo } @@ -3146,6 +3160,18 @@ extension RepositoriesFeature.State { return nil } + /// Tint colors for scripts currently running in the given worktree, + /// ordered deterministically by script ID. Falls back to `.green` + /// for script IDs not found in the lookup (e.g. when the selected + /// worktree belongs to a different repository). + func runningScriptColors( + for worktreeID: Worktree.ID, + scriptsByID: [UUID: ScriptDefinition] + ) -> [TerminalTabTintColor] { + guard let scriptIDs = runningScriptsByWorktreeID[worktreeID] else { return [] } + return scriptIDs.sorted().map { scriptsByID[$0]?.resolvedTintColor ?? .green } + } + func pendingWorktree(for id: Worktree.ID?) -> PendingWorktree? { guard let id else { return nil } return pendingWorktrees.first(where: { $0.id == id }) diff --git a/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift b/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift index e5643127e..f3c143f3d 100644 --- a/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift +++ b/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift @@ -20,7 +20,13 @@ struct OpenWorktreeActionMenuLabelView: View { } var body: some View { - HStack(spacing: 6) { + Label { + if let shortcutHint { + Text(shortcutHint) + } else { + Text(action.labelTitle) + } + } icon: { if let icon = action.menuIcon { switch icon { case .app(let image): @@ -33,18 +39,6 @@ struct OpenWorktreeActionMenuLabelView: View { .accessibilityHidden(true) } } - if let shortcutHint { - HStack(spacing: 2) { - Text(action.labelTitle) - .font(.body) - Text("(\(shortcutHint))") - .font(.body) - .foregroundStyle(.secondary) - } - } else { - Text(action.labelTitle) - .font(.body) - } - } + }.labelStyle(.titleAndIcon) } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 5a026af56..ed11e5677 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -1,6 +1,7 @@ import AppKit import ComposableArchitecture import Sharing +import SupacodeSettingsFeature import SupacodeSettingsShared import SwiftUI @@ -38,8 +39,8 @@ struct WorktreeDetailView: View { && loadingInfo == nil && !showsMultiSelectionSummary let openActionSelection = state.openActionSelection - let runScriptEnabled = hasActiveWorktree - let runScriptIsRunning = selectedWorktree.map { state.repositories.runScriptWorktreeIDs.contains($0.id) } == true + let scripts = state.scripts + let runningScriptIDs = state.runningScriptIDs let notificationGroups = repositories.toolbarNotificationGroups(terminalManager: terminalManager) let unseenNotificationWorktreeCount = notificationGroups.reduce(0) { count, repository in count + repository.unseenWorktreeCount @@ -70,8 +71,8 @@ struct WorktreeDetailView: View { unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, openActionSelection: openActionSelection, showExtras: commandKeyObserver.isPressed, - runScriptEnabled: runScriptEnabled, - runScriptIsRunning: runScriptIsRunning + scripts: scripts, + runningScriptIDs: runningScriptIDs, ) WorktreeToolbarContent( toolbarState: toolbarState, @@ -90,14 +91,20 @@ struct WorktreeDetailView: View { onSelectNotification: selectToolbarNotification, onDismissAllNotifications: { dismissAllToolbarNotifications(in: notificationGroups) }, onRunScript: { store.send(.runScript) }, - onStopRunScript: { store.send(.stopRunScript) } + onRunNamedScript: { store.send(.runNamedScript($0)) }, + onStopScript: { store.send(.stopScript($0)) }, + onStopRunScripts: { store.send(.stopRunScripts) }, + onManageScripts: { + let repositoryID = selectedWorktree.repositoryRootURL.path(percentEncoded: false) + store.send(.settings(.setSelection(.repositoryScripts(repositoryID)))) + } ) } } + let hasRunningRunScript = state.hasRunningRunScript let actions = makeFocusedActions( hasActiveWorktree: hasActiveWorktree, - runScriptEnabled: runScriptEnabled, - runScriptIsRunning: runScriptIsRunning + hasRunningRunScript: hasRunningRunScript ) return applyFocusedActions(content: content, actions: actions) } @@ -226,8 +233,7 @@ struct WorktreeDetailView: View { private func makeFocusedActions( hasActiveWorktree: Bool, - runScriptEnabled: Bool, - runScriptIsRunning: Bool + hasRunningRunScript: Bool ) -> FocusedActions { func action(_ appAction: AppFeature.Action) -> (() -> Void)? { hasActiveWorktree ? { store.send(appAction) } : nil @@ -243,8 +249,8 @@ struct WorktreeDetailView: View { navigateSearchNext: action(.navigateSearchNext), navigateSearchPrevious: action(.navigateSearchPrevious), endSearch: action(.endSearch), - runScript: runScriptEnabled ? { store.send(.runScript) } : nil, - stopRunScript: runScriptIsRunning ? { store.send(.stopRunScript) } : nil + runScript: hasActiveWorktree ? { store.send(.runScript) } : nil, + stopRunScript: hasRunningRunScript ? { store.send(.stopRunScripts) } : nil, ) } @@ -289,8 +295,18 @@ struct WorktreeDetailView: View { let unseenNotificationWorktreeCount: Int let openActionSelection: OpenWorktreeAction let showExtras: Bool - let runScriptEnabled: Bool - let runScriptIsRunning: Bool + let scripts: [ScriptDefinition] + let runningScriptIDs: Set + + /// The first `.run`-kind script, if any. + var primaryScript: ScriptDefinition? { + scripts.primaryScript + } + + /// Whether any `.run`-kind script is currently running. + var hasRunningRunScript: Bool { + scripts.hasRunningRunScript(in: runningScriptIDs) + } var runScriptHelpText: String { @Shared(.settingsFile) var settingsFile @@ -314,7 +330,10 @@ struct WorktreeDetailView: View { let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void let onDismissAllNotifications: () -> Void let onRunScript: () -> Void - let onStopRunScript: () -> Void + let onRunNamedScript: (ScriptDefinition) -> Void + let onStopScript: (ScriptDefinition) -> Void + let onStopRunScripts: () -> Void + let onManageScripts: () -> Void var body: some ToolbarContent { ToolbarItem { @@ -348,7 +367,7 @@ struct WorktreeDetailView: View { ToolbarSpacer(.flexible) - ToolbarItemGroup { + ToolbarItem { openMenu( openActionSelection: toolbarState.openActionSelection, showExtras: toolbarState.showExtras @@ -356,19 +375,15 @@ struct WorktreeDetailView: View { } ToolbarSpacer(.fixed) - if toolbarState.runScriptIsRunning || toolbarState.runScriptEnabled { - ToolbarItem { - RunScriptToolbarButton( - isRunning: toolbarState.runScriptIsRunning, - isEnabled: toolbarState.runScriptEnabled, - runHelpText: toolbarState.runScriptHelpText, - stopHelpText: toolbarState.stopRunScriptHelpText, - runShortcut: shortcutDisplay(for: AppShortcuts.runScript, fallback: ""), - stopShortcut: shortcutDisplay(for: AppShortcuts.stopRunScript, fallback: ""), - runAction: onRunScript, - stopAction: onStopRunScript - ) - } + ToolbarItem { + ScriptMenu( + toolbarState: toolbarState, + onRunScript: onRunScript, + onRunNamedScript: onRunNamedScript, + onStopScript: onStopScript, + onStopRunScripts: onStopRunScripts, + onManageScripts: onManageScripts + ) } } @@ -379,55 +394,40 @@ struct WorktreeDetailView: View { let resolved = OpenWorktreeAction.availableSelection(openActionSelection) let primarySelection = resolved == .finder ? availableActions.first : resolved if let primarySelection { - Button { - onOpenWorktree(primarySelection) + Menu { + ForEach(availableActions) { action in + let isDefault = action == primarySelection + Button { + onOpenActionSelectionChanged(action) + onOpenWorktree(action) + } label: { + OpenWorktreeActionMenuLabelView(action: action, shortcutHint: nil) + } + .buttonStyle(.plain) + .help(openActionHelpText(for: action, isDefault: isDefault)) + } + Divider() + Button { + onRevealInFinder() + } label: { + OpenWorktreeActionMenuLabelView(action: .finder, shortcutHint: nil) + } + .help("Reveal in Finder (\(resolveShortcutDisplay(for: AppShortcuts.revealInFinder)))") } label: { OpenWorktreeActionMenuLabelView( action: primarySelection, - shortcutHint: showExtras ? shortcutDisplay(for: AppShortcuts.openWorktree, fallback: "") : nil + shortcutHint: showExtras ? resolveShortcutDisplay(for: AppShortcuts.openWorktree, fallback: "") : nil ) + } primaryAction: { + onOpenWorktree(primarySelection) } .help(openActionHelpText(for: primarySelection, isDefault: true)) } - - Menu { - ForEach(availableActions) { action in - let isDefault = action == primarySelection - Button { - onOpenActionSelectionChanged(action) - onOpenWorktree(action) - } label: { - OpenWorktreeActionMenuLabelView(action: action, shortcutHint: nil) - } - .buttonStyle(.plain) - .help(openActionHelpText(for: action, isDefault: isDefault)) - } - Divider() - Button { - onRevealInFinder() - } label: { - OpenWorktreeActionMenuLabelView(action: .finder, shortcutHint: nil) - } - .help("Reveal in Finder (\(shortcutDisplay(for: AppShortcuts.revealInFinder)))") - } label: { - Image(systemName: "chevron.down") - .font(.caption2) - .accessibilityLabel("Open in menu") - } - .imageScale(.small) - .menuIndicator(.hidden) - .fixedSize() - .help("Open in…") - } - - private func shortcutDisplay(for shortcut: AppShortcut, fallback: String = "none") -> String { - @Shared(.settingsFile) var settingsFile - return shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback } private func openActionHelpText(for action: OpenWorktreeAction, isDefault: Bool) -> String { guard isDefault else { return action.title } - return "\(action.title) (\(shortcutDisplay(for: AppShortcuts.openWorktree)))" + return "\(action.title) (\(resolveShortcutDisplay(for: AppShortcuts.openWorktree)))" } } @@ -590,12 +590,13 @@ private struct ToolbarPlaceholderContent: ToolbarContent { ToolbarItem { Button { } label: { - HStack(spacing: 6) { - Image(systemName: "play.fill") + Label { Text("Run") + } icon: { + Image(systemName: "play") } + .labelStyle(.titleAndIcon) } - .font(.caption) .redacted(reason: .placeholder) .shimmer(isActive: true) } @@ -608,6 +609,13 @@ private struct MultiSelectedWorktreeSummary: Identifiable { let repositoryName: String? } +/// Resolves a shortcut's display string from the user's settings. +private func resolveShortcutDisplay(for shortcut: AppShortcut, fallback: String = "none") -> String { + @Shared(.settingsFile) var settingsFile + let display = shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback + return display.isEmpty ? fallback : display +} + private struct MultiSelectedWorktreesDetailView: View { let rows: [MultiSelectedWorktreeSummary] @@ -655,70 +663,91 @@ private struct MultiSelectedWorktreesDetailView: View { } } -private struct RunScriptToolbarButton: View { - let isRunning: Bool - let isEnabled: Bool - let runHelpText: String - let stopHelpText: String - let runShortcut: String - let stopShortcut: String - let runAction: () -> Void - let stopAction: () -> Void +/// Menu with primary action for running scripts in the toolbar. +/// Click runs the default script, stops running scripts, or opens settings; +/// long-press/arrow opens the full script list. +private struct ScriptMenu: View { + let toolbarState: WorktreeDetailView.WorktreeToolbarState + let onRunScript: () -> Void + let onRunNamedScript: (ScriptDefinition) -> Void + let onStopScript: (ScriptDefinition) -> Void + let onStopRunScripts: () -> Void + let onManageScripts: () -> Void @Environment(CommandKeyObserver.self) private var commandKeyObserver - var body: some View { - if isRunning { - button( - config: RunScriptButtonConfig( - title: "Stop", - systemImage: "stop.fill", - helpText: stopHelpText, - shortcut: stopShortcut, - isEnabled: true, - action: stopAction - )) - } else { - button( - config: RunScriptButtonConfig( - title: "Run", - systemImage: "play.fill", - helpText: runHelpText, - shortcut: runShortcut, - isEnabled: isEnabled, - action: runAction - )) - } + private var primaryScript: ScriptDefinition? { + toolbarState.primaryScript } - @ViewBuilder - private func button(config: RunScriptButtonConfig) -> some View { - Button { - config.action() - } label: { - HStack(spacing: 6) { - Image(systemName: config.systemImage) - .accessibilityHidden(true) - Text(config.title) - - if commandKeyObserver.isPressed { - Text(config.shortcut) - .font(.caption) - .foregroundStyle(.secondary) + var body: some View { + let hasRunning = toolbarState.hasRunningRunScript + Menu { + ForEach(toolbarState.scripts) { script in + let isRunning = toolbarState.runningScriptIDs.contains(script.id) + Button { + if isRunning { + onStopScript(script) + } else { + onRunNamedScript(script) + } + } label: { + Label { + Text(isRunning ? "Stop \(script.displayName)" : script.displayName) + } icon: { + Image.tintedSymbol( + isRunning ? "stop" : script.resolvedSystemImage, + color: script.resolvedTintColor.nsColor, + ) + } } + .help(isRunning ? "Stop \(script.displayName)." : "Run \(script.displayName).") + } + if !toolbarState.scripts.isEmpty { + Divider() + } + Button("Manage Scripts…") { + onManageScripts() + } + .help("Open repository settings to manage scripts.") + } label: { + scriptLabel(hasRunning: hasRunning) + } primaryAction: { + if hasRunning { + onStopRunScripts() + } else if primaryScript != nil { + onRunScript() + } else { + onManageScripts() } } - .font(.caption) - .help(config.helpText) - .disabled(!config.isEnabled) + .help(primaryHelpText(hasRunning: hasRunning)) } - private struct RunScriptButtonConfig { - let title: String - let systemImage: String - let helpText: String - let shortcut: String - let isEnabled: Bool - let action: () -> Void + @ViewBuilder + private func scriptLabel(hasRunning: Bool) -> some View { + let icon = hasRunning ? "stop" : (primaryScript?.resolvedSystemImage ?? "play") + let label = hasRunning ? "Stop" : (primaryScript?.displayName ?? "Run") + let shortcut = hasRunning ? AppShortcuts.stopRunScript : AppShortcuts.runScript + Label { + Text( + commandKeyObserver.isPressed + ? resolveShortcutDisplay(for: shortcut, fallback: label) + : label + ) + } icon: { + Image(systemName: icon) + .accessibilityHidden(true) + }.labelStyle(.titleAndIcon) + } + + private func primaryHelpText(hasRunning: Bool) -> String { + if hasRunning { + return toolbarState.stopRunScriptHelpText + } + guard primaryScript != nil else { + return "Configure scripts in Settings." + } + return toolbarState.runScriptHelpText } } @@ -736,8 +765,8 @@ private struct WorktreeToolbarPreview: View { unseenNotificationWorktreeCount: 0, openActionSelection: .finder, showExtras: false, - runScriptEnabled: true, - runScriptIsRunning: false + scripts: [ScriptDefinition(kind: .run, command: "npm run dev")], + runningScriptIDs: [], ) let observer = CommandKeyObserver() observer.isPressed = false @@ -759,7 +788,10 @@ private struct WorktreeToolbarPreview: View { onSelectNotification: { _, _ in }, onDismissAllNotifications: {}, onRunScript: {}, - onStopRunScript: {} + onRunNamedScript: { _ in }, + onStopScript: { _ in }, + onStopRunScripts: {}, + onManageScripts: {} ) } .environment(commandKeyObserver) diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index 0cd996975..7ff16f83d 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -14,7 +14,7 @@ struct WorktreeRow: View { let info: WorktreeInfoEntry? let pullRequestBadgeText: String? let showsPullRequestInfo: Bool - let isRunScriptRunning: Bool + let runningScriptColors: [TerminalTabTintColor] let showsNotificationIndicator: Bool let notifications: [WorktreeTerminalNotification] let shortcutHint: String? @@ -62,7 +62,7 @@ struct WorktreeRow: View { hideSubtitle: Bool, hideSubtitleOnMatch: Bool, showsPullRequestInfo: Bool, - isRunScriptRunning: Bool, + runningScriptColors: [TerminalTabTintColor], isTaskRunning: Bool, showsNotificationIndicator: Bool, notifications: [WorktreeTerminalNotification], @@ -73,7 +73,7 @@ struct WorktreeRow: View { self.isPending = row.isPending self.info = row.info self.showsPullRequestInfo = showsPullRequestInfo - self.isRunScriptRunning = isRunScriptRunning + self.runningScriptColors = runningScriptColors self.showsNotificationIndicator = showsNotificationIndicator self.notifications = notifications self.shortcutHint = shortcutHint @@ -178,7 +178,7 @@ struct WorktreeRow: View { info: info, showsPullRequestInfo: showsPullRequestInfo, pullRequestBadgeText: pullRequestBadgeText, - isRunScriptRunning: isRunScriptRunning, + runningScriptColors: runningScriptColors, showsNotificationIndicator: showsNotificationIndicator, notifications: notifications ) @@ -315,7 +315,7 @@ private struct TrailingView: View { let info: WorktreeInfoEntry? let showsPullRequestInfo: Bool let pullRequestBadgeText: String? - let isRunScriptRunning: Bool + let runningScriptColors: [TerminalTabTintColor] let showsNotificationIndicator: Bool let notifications: [WorktreeTerminalNotification] @@ -334,7 +334,7 @@ private struct TrailingView: View { .transition(.blurReplace) } StatusIndicator( - isRunScriptRunning: isRunScriptRunning, + runningScriptColors: runningScriptColors, showsNotificationIndicator: showsNotificationIndicator, notifications: notifications ) @@ -368,7 +368,7 @@ private struct DiffStatsView: View { // MARK: - Status indicator. private struct StatusIndicator: View { - let isRunScriptRunning: Bool + let runningScriptColors: [TerminalTabTintColor] let showsNotificationIndicator: Bool let notifications: [WorktreeTerminalNotification] @Environment(\.backgroundProminence) private var backgroundProminence @@ -376,11 +376,13 @@ private struct StatusIndicator: View { var body: some View { let isEmphasized = backgroundProminence == .increased - if isRunScriptRunning || showsNotificationIndicator { + let isRunning = !runningScriptColors.isEmpty + if isRunning || showsNotificationIndicator { ZStack { - if isRunScriptRunning { - PingDot( - style: isEmphasized ? AnyShapeStyle(.primary) : AnyShapeStyle(.green), + if isRunning { + MultiColorPingDot( + colors: runningScriptColors, + isEmphasized: isEmphasized, size: 6, showsSolidCenter: !showsNotificationIndicator ) @@ -400,19 +402,101 @@ private struct StatusIndicator: View { } } -// MARK: - Vertically centered label style. +// MARK: - Multi-color ping dot. -private struct VerticallyCenteredLabelStyle: LabelStyle { - func makeBody(configuration: Configuration) -> some View { - HStack(spacing: 6) { - configuration.icon - configuration.title +/// Displays a pulsing dot that cycles through multiple script tint +/// colors when more than one script is running. Falls back to the +/// single-color pulsing behavior when only one color is present. +private struct MultiColorPingDot: View { + let colors: [TerminalTabTintColor] + let isEmphasized: Bool + let size: CGFloat + let showsSolidCenter: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// Unique, ordered colors derived from the input. + private var uniqueColors: [Color] { + guard !isEmphasized else { return [.primary] } + var seen = Set() + return colors.compactMap { tint in + guard seen.insert(tint).inserted else { return nil } + return tint.color + } + } + + var body: some View { + let resolved = uniqueColors + if resolved.count <= 1 { + PingDot( + style: resolved.first.map { AnyShapeStyle($0) } ?? AnyShapeStyle(.green), + size: size, + showsSolidCenter: showsSolidCenter + ) + } else if reduceMotion { + // Show a static dot with the first color when motion is reduced. + StaticDot(color: resolved[0], size: size, showsSolidCenter: showsSolidCenter) + } else { + CyclingDot(colors: resolved, size: size, showsSolidCenter: showsSolidCenter) + } + } +} + +/// Static dot used when accessibility reduce-motion is enabled. +private struct StaticDot: View { + let color: Color + let size: CGFloat + let showsSolidCenter: Bool + + var body: some View { + ZStack { + Circle() + .stroke(color, lineWidth: 1) + .frame(width: size, height: size) + .opacity(0.6) + if showsSolidCenter { + Circle() + .fill(color) + .frame(width: size, height: size) + } } + .accessibilityLabel("Run script active") } } -extension LabelStyle where Self == VerticallyCenteredLabelStyle { - static var verticallyCentered: VerticallyCenteredLabelStyle { .init() } +/// Animated dot that smoothly cycles through the provided colors. +private struct CyclingDot: View { + let colors: [Color] + let size: CGFloat + let showsSolidCenter: Bool + @State private var isPinging = false + + var body: some View { + TimelineView(.periodic(from: .now, by: 2.0)) { timeline in + let index = Self.colorIndex(for: timeline.date, count: colors.count) + ZStack { + Circle() + .stroke(colors[index], lineWidth: 1) + .frame(width: size, height: size) + .scaleEffect(isPinging ? 2 : 1) + .opacity(isPinging ? 0 : 0.6) + .animation(.easeOut(duration: 1).repeatForever(autoreverses: false), value: isPinging) + if showsSolidCenter { + Circle() + .fill(colors[index]) + .frame(width: size, height: size) + } + } + .animation(.easeInOut(duration: 0.6), value: index) + } + .accessibilityLabel("Run script active") + .task { isPinging = true } + } + + private static func colorIndex(for date: Date, count: Int) -> Int { + guard count > 0 else { return 0 } + let seconds = Int(date.timeIntervalSinceReferenceDate) + return (seconds / 2) % count + } } // MARK: - Pulsing dot. diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index a66fad6c7..72008c7af 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -153,6 +153,7 @@ private struct WorktreeRowContainer: View { let shortcutHint: String? @Shared(.appStorage("worktreeRowDisplayMode")) private var displayMode: WorktreeRowDisplayMode = .branchFirst @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true + @Environment(\.scriptsByID) private var scriptsByID var body: some View { WorktreeRow( @@ -161,7 +162,7 @@ private struct WorktreeRowContainer: View { hideSubtitle: hideSubtitle, hideSubtitleOnMatch: hideSubtitleOnMatch, showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), - isRunScriptRunning: store.state.runScriptWorktreeIDs.contains(row.id), + runningScriptColors: store.state.runningScriptColors(for: row.id, scriptsByID: scriptsByID), isTaskRunning: terminalManager.stateIfExists(for: row.id)?.taskStatus == .running, showsNotificationIndicator: terminalManager.hasUnseenNotifications(for: row.id), notifications: terminalManager.stateIfExists(for: row.id)?.notifications ?? [], diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 45fe16674..2f1f164b4 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -38,9 +38,145 @@ private struct RepositoryLabel: View { } } +/// Disclosure group label for a repository in the settings sidebar. +private struct RepositoryDisclosureLabel: View { + let repository: SettingsRepositorySummary + @Bindable var settingsStore: StoreOf + @Binding var isExpanded: Bool + + private var isSelected: Bool { + settingsStore.selection?.repositoryID == repository.id + } + + var body: some View { + RepositoryLabel(name: repository.name, rootURL: repository.rootURL) + .contentShape(Rectangle()) + .accessibilityAddTraits(.isButton) + .onTapGesture { + guard !isSelected else { + isExpanded.toggle() + return + } + _ = settingsStore.send(.setSelection(.repository(repository.id))) + } + } +} + +/// Sidebar content for the settings split view. +private struct SettingsSidebarView: View { + @Bindable var settingsStore: StoreOf + @Binding var expandedRepositories: Set + + var body: some View { + List(selection: $settingsStore.selection.sending(\.setSelection)) { + Label("General", systemImage: "gearshape") + .tag(SettingsSection.general) + Label("Notifications", systemImage: "bell") + .tag(SettingsSection.notifications) + Label("Worktrees", systemImage: "list.dash") + .tag(SettingsSection.worktree) + Label("Developer", systemImage: "hammer") + .tag(SettingsSection.developer) + Label("GitHub", image: "github-mark") + .tag(SettingsSection.github) + Label("Shortcuts", systemImage: "keyboard") + .tag(SettingsSection.shortcuts) + Label("Updates", systemImage: "arrow.down.circle") + .tag(SettingsSection.updates) + + Section("Repositories") { + ForEach(settingsStore.repositorySummaries, id: \.id) { repository in + let isExpanded = Binding( + get: { expandedRepositories.contains(repository.id) }, + set: { expanded in + if expanded { + expandedRepositories.insert(repository.id) + } else { + expandedRepositories.remove(repository.id) + } + } + ) + DisclosureGroup(isExpanded: isExpanded) { + Label("General", systemImage: "gearshape") + .tag(SettingsSection.repository(repository.id)) + Label("Scripts", systemImage: "terminal") + .tag(SettingsSection.repositoryScripts(repository.id)) + } label: { + RepositoryDisclosureLabel( + repository: repository, + settingsStore: settingsStore, + isExpanded: isExpanded + ) + } + } + } + } + .listStyle(.sidebar) + .frame(minWidth: 220, maxHeight: .infinity) + .navigationSplitViewColumnWidth(220) + .toolbar(removing: .sidebarToggle) + } +} + +/// Detail pane content for the settings split view. +private struct SettingsDetailView: View { + let selection: SettingsSection + let selectedRepositorySummary: SettingsRepositorySummary? + @Bindable var settingsStore: StoreOf + let updatesStore: StoreOf + + var body: some View { + switch selection { + case .general: + AppearanceSettingsView(store: settingsStore) + case .notifications: + NotificationsSettingsView(store: settingsStore) + case .worktree: + WorktreeSettingsView(store: settingsStore) + case .developer: + DeveloperSettingsView(store: settingsStore) + case .shortcuts: + KeyboardShortcutsSettingsView(store: settingsStore) + case .updates: + UpdatesSettingsView(settingsStore: settingsStore, updatesStore: updatesStore) + case .github: + GithubSettingsView(store: settingsStore) + case .repository: + if let repository = selectedRepositorySummary { + IfLetStore(settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings)) { + repositorySettingsStore in + RepositorySettingsView(store: repositorySettingsStore) + .id(repository.id) + .navigationTitle(repository.name) + } + } else { + Text("Repository not found.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .navigationTitle("Repositories") + } + case .repositoryScripts: + if let repository = selectedRepositorySummary { + IfLetStore(settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings)) { + repositorySettingsStore in + RepositoryScriptsSettingsView(store: repositorySettingsStore) + .id("\(repository.id)-scripts") + .navigationTitle("\(repository.name) — Scripts") + } + } else { + Text("Repository not found.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .navigationTitle("Scripts") + } + } + } +} + struct SettingsView: View { @Bindable var store: StoreOf @Bindable var settingsStore: StoreOf + @State private var expandedRepositories: Set = [] init(store: StoreOf) { self.store = store @@ -51,71 +187,29 @@ struct SettingsView: View { let updatesStore = store.scope(state: \.updates, action: \.updates) let selection = settingsStore.selection ?? .general let selectedRepositorySummary: SettingsRepositorySummary? = { - guard case .repository(let repositoryID) = selection else { + guard let repositoryID = selection.repositoryID else { return nil } return settingsStore.repositorySummaries.first(where: { $0.id == repositoryID }) }() NavigationSplitView(columnVisibility: .constant(.all)) { - List(selection: $settingsStore.selection.sending(\.setSelection)) { - Label("General", systemImage: "gearshape") - .tag(SettingsSection.general) - Label("Notifications", systemImage: "bell") - .tag(SettingsSection.notifications) - Label("Worktrees", systemImage: "list.dash") - .tag(SettingsSection.worktree) - Label("Developer", systemImage: "hammer") - .tag(SettingsSection.developer) - Label("GitHub", image: "github-mark") - .tag(SettingsSection.github) - Label("Shortcuts", systemImage: "keyboard") - .tag(SettingsSection.shortcuts) - Label("Updates", systemImage: "arrow.down.circle") - .tag(SettingsSection.updates) - - Section("Repositories") { - ForEach(settingsStore.repositorySummaries, id: \.id) { repository in - RepositoryLabel(name: repository.name, rootURL: repository.rootURL) - .tag(SettingsSection.repository(repository.id)) - } - } + SettingsSidebarView( + settingsStore: settingsStore, + expandedRepositories: $expandedRepositories + ) + .onChange(of: selection) { _, newSelection in + // Auto-expand the repository disclosure group when navigating to it. + guard let repositoryID = newSelection.repositoryID else { return } + expandedRepositories.insert(repositoryID) } - .listStyle(.sidebar) - .frame(minWidth: 220, maxHeight: .infinity) - .navigationSplitViewColumnWidth(220) - .toolbar(removing: .sidebarToggle) } detail: { - switch selection { - case .general: - AppearanceSettingsView(store: settingsStore) - case .notifications: - NotificationsSettingsView(store: settingsStore) - case .worktree: - WorktreeSettingsView(store: settingsStore) - case .developer: - DeveloperSettingsView(store: settingsStore) - case .shortcuts: - KeyboardShortcutsSettingsView(store: settingsStore) - case .updates: - UpdatesSettingsView(settingsStore: settingsStore, updatesStore: updatesStore) - case .github: - GithubSettingsView(store: settingsStore) - case .repository: - if let repository = selectedRepositorySummary { - IfLetStore(settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings)) { - repositorySettingsStore in - RepositorySettingsView(store: repositorySettingsStore) - .id(repository.id) - .navigationTitle(repository.name) - } - } else { - Text("Repository not found.") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .navigationTitle("Repositories") - } - } + SettingsDetailView( + selection: selection, + selectedRepositorySummary: selectedRepositorySummary, + settingsStore: settingsStore, + updatesStore: updatesStore + ) } .toolbar { // Invisible item keeps the toolbar stable when switching between diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 78c9c98e0..93cfe4592 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -122,7 +122,9 @@ final class WorktreeTerminalManager { let state = state(for: worktree) { runSetupScriptIfNew } state.ensureInitialTab(focusing: focusing) case .stopRunScript(let worktree): - _ = state(for: worktree).stopRunScript() + _ = state(for: worktree).stopRunScripts() + case .stopScript(let worktree, let definitionID): + _ = state(for: worktree).stopScript(definitionID: definitionID) case .runBlockingScript(let worktree, let kind, let script): _ = state(for: worktree).runBlockingScript(kind: kind, script) case .closeFocusedTab(let worktree): @@ -187,10 +189,10 @@ final class WorktreeTerminalManager { state(for: worktree).navigateSearchOnFocusedSurface(.previous) case .endSearch(let worktree): state(for: worktree).performBindingActionOnFocusedSurface("end_search") - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .runBlockingScript, - .closeFocusedTab, .closeFocusedSurface, .performBindingAction, .selectTab, .focusSurface, - .splitSurface, .destroyTab, .destroySurface, .prune, .setNotificationsEnabled, - .setSelectedWorktreeID, .refreshTabBarVisibility: + case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, + .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, + .selectTab, .focusSurface, .splitSurface, .destroyTab, .destroySurface, .prune, + .setNotificationsEnabled, .setSelectedWorktreeID, .refreshTabBarVisibility: return false } return true @@ -200,11 +202,11 @@ final class WorktreeTerminalManager { switch command { case .performBindingAction(let worktree, let action): state(for: worktree).performBindingActionOnFocusedSurface(action) - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .runBlockingScript, - .closeFocusedTab, .closeFocusedSurface, .startSearch, .searchSelection, .navigateSearchNext, - .navigateSearchPrevious, .endSearch, .selectTab, .focusSurface, .splitSurface, .destroyTab, - .destroySurface, .prune, .setNotificationsEnabled, .setSelectedWorktreeID, - .refreshTabBarVisibility: + case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, + .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .startSearch, .searchSelection, + .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .focusSurface, + .splitSurface, .destroyTab, .destroySurface, .prune, .setNotificationsEnabled, + .setSelectedWorktreeID, .refreshTabBarVisibility: return false } return true @@ -228,10 +230,10 @@ final class WorktreeTerminalManager { } selectedWorktreeID = id terminalLogger.info("Selected worktree \(id ?? "nil")") - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .runBlockingScript, - .closeFocusedTab, .closeFocusedSurface, .performBindingAction, .startSearch, .searchSelection, - .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .focusSurface, - .splitSurface, .destroyTab, .destroySurface: + case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, + .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, + .startSearch, .searchSelection, .navigateSearchNext, .navigateSearchPrevious, .endSearch, + .selectTab, .focusSurface, .splitSurface, .destroyTab, .destroySurface: assertionFailure("Unhandled terminal command reached management handler: \(command)") } } diff --git a/supacode/Features/Terminal/Models/BlockingScriptKind.swift b/supacode/Features/Terminal/Models/BlockingScriptKind.swift index 8dd488c67..e88daa65f 100644 --- a/supacode/Features/Terminal/Models/BlockingScriptKind.swift +++ b/supacode/Features/Terminal/Models/BlockingScriptKind.swift @@ -1,15 +1,22 @@ +import Foundation +import SupacodeSettingsShared + /// Identifies the kind of script that runs in a dedicated terminal tab -/// with exit-code tracking. Some kinds (archive, delete) block worktree -/// state transitions until the script completes. Adding a new case -/// requires handling in `AppFeature`'s `.blockingScriptCompleted` event router. -enum BlockingScriptKind: Hashable, Sendable, CaseIterable { - case run +/// with exit-code tracking. `.archive` and `.delete` block worktree +/// state transitions until the script completes. `.script` wraps a +/// user-defined `ScriptDefinition` and can run concurrently. +/// +/// Equality and hashing for the `.script` case use only the +/// definition's `id`, so dictionary lookups and dedup checks remain +/// stable even when the user edits the script's name or command. +enum BlockingScriptKind: Sendable { + case script(ScriptDefinition) case archive case delete var tabTitle: String { switch self { - case .run: "Run Script" + case .script(let definition): definition.displayName case .archive: "Archive Script" case .delete: "Delete Script" } @@ -17,7 +24,7 @@ enum BlockingScriptKind: Hashable, Sendable, CaseIterable { var tabIcon: String { switch self { - case .run: "play.fill" + case .script(let definition): definition.resolvedSystemImage case .archive: "archivebox.fill" case .delete: "trash.fill" } @@ -25,9 +32,52 @@ enum BlockingScriptKind: Hashable, Sendable, CaseIterable { var tabColor: TerminalTabTintColor { switch self { - case .run: .green + case .script(let definition): definition.resolvedTintColor case .archive: .orange case .delete: .red } } + + /// The script definition ID for user-defined scripts, `nil` for lifecycle scripts. + var scriptDefinitionID: UUID? { + switch self { + case .script(let definition): definition.id + case .archive, .delete: nil + } + } + + /// `true` when this is a `.run`-kind script — the only kind + /// stopped by the global Stop action (Cmd+.), since Stop is + /// the semantic counterpart of Run. + var isRunKind: Bool { + switch self { + case .script(let definition): definition.kind == .run + case .archive, .delete: false + } + } +} + +// MARK: - Hashable / Equatable + +extension BlockingScriptKind: Hashable { + static func == (lhs: BlockingScriptKind, rhs: BlockingScriptKind) -> Bool { + switch (lhs, rhs) { + case (.script(let lhsDef), .script(let rhsDef)): lhsDef.id == rhsDef.id + case (.archive, .archive): true + case (.delete, .delete): true + default: false + } + } + + func hash(into hasher: inout Hasher) { + switch self { + case .script(let definition): + hasher.combine(0) + hasher.combine(definition.id) + case .archive: + hasher.combine(1) + case .delete: + hasher.combine(2) + } + } } diff --git a/supacode/Features/Terminal/Models/TerminalLayoutSnapshot.swift b/supacode/Features/Terminal/Models/TerminalLayoutSnapshot.swift index fe17499b8..b1afe30e0 100644 --- a/supacode/Features/Terminal/Models/TerminalLayoutSnapshot.swift +++ b/supacode/Features/Terminal/Models/TerminalLayoutSnapshot.swift @@ -1,4 +1,5 @@ import Foundation +import SupacodeSettingsShared struct TerminalLayoutSnapshot: Codable, Equatable, Sendable { let tabs: [TabSnapshot] diff --git a/supacode/Features/Terminal/Models/TerminalTabItem.swift b/supacode/Features/Terminal/Models/TerminalTabItem.swift index ab60facb2..946dba315 100644 --- a/supacode/Features/Terminal/Models/TerminalTabItem.swift +++ b/supacode/Features/Terminal/Models/TerminalTabItem.swift @@ -1,4 +1,5 @@ import Foundation +import SupacodeSettingsShared struct TerminalTabItem: Identifiable, Equatable, Sendable { let id: TerminalTabID diff --git a/supacode/Features/Terminal/Models/TerminalTabTintColor.swift b/supacode/Features/Terminal/Models/TerminalTabTintColor.swift deleted file mode 100644 index a3c26e4ab..000000000 --- a/supacode/Features/Terminal/Models/TerminalTabTintColor.swift +++ /dev/null @@ -1,17 +0,0 @@ -import SwiftUI - -/// Color token for terminal tab tint indicators, used in place of -/// `Color` so that `TerminalTabItem` can remain `Equatable` and `Sendable`. -enum TerminalTabTintColor: String, Codable, Hashable, Sendable { - case green - case orange - case red - - var color: Color { - switch self { - case .green: .green - case .orange: .orange - case .red: .red - } - } -} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 35f9cd875..7c18cc43a 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -168,7 +168,7 @@ final class WorktreeTerminalState { let tabId = createTab( TabCreation( title: title, - icon: "terminal", + icon: nil, isTitleLocked: false, command: nil, initialInput: resolvedInput, @@ -184,13 +184,41 @@ final class WorktreeTerminalState { return tabId } + /// Stops a single user-defined script identified by its definition ID. @discardableResult - func stopRunScript() -> Bool { - guard let tabId = blockingScripts.first(where: { $0.value == .run })?.key else { return false } + func stopScript(definitionID: UUID) -> Bool { + guard + let tabId = blockingScripts.first(where: { $0.value.scriptDefinitionID == definitionID })?.key + else { return false } closeTab(tabId) return true } + /// Stops all running `.run`-kind scripts. Intentionally excludes + /// non-run scripts (test, deploy, etc.) because the Stop action + /// (Cmd+.) is the semantic counterpart of Run, not a "stop + /// everything" command. Other kinds are stopped individually + /// via the script menu or command palette. + @discardableResult + func stopRunScripts() -> Bool { + let runTabIds = blockingScripts.filter { $0.value.isRunKind }.map(\.key) + guard !runTabIds.isEmpty else { return false } + for tabId in runTabIds { + closeTab(tabId) + } + return true + } + + /// Returns the set of script definition IDs currently running. + func runningScriptDefinitionIDs() -> Set { + Set(blockingScripts.values.compactMap(\.scriptDefinitionID)) + } + + /// Checks whether a user-defined script with the given definition ID is running. + func isScriptRunning(definitionID: UUID) -> Bool { + blockingScripts.values.contains(where: { $0.scriptDefinitionID == definitionID }) + } + @discardableResult func runBlockingScript(kind: BlockingScriptKind, _ script: String) -> TerminalTabID? { let launch: BlockingScriptLaunch diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift index 6b2d25b8b..bb6a67e1f 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift @@ -1,3 +1,4 @@ +import SupacodeSettingsShared import SwiftUI struct TerminalTabBackground: View { diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift index 8e2bd4ee7..4155fd960 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -1,3 +1,4 @@ +import SupacodeSettingsShared import SwiftUI struct TerminalTabLabelView: View { diff --git a/supacodeTests/AgentHookSettingsFileInstallerTests.swift b/supacodeTests/AgentHookSettingsFileInstallerTests.swift index 8a40934fa..5afb3874c 100644 --- a/supacodeTests/AgentHookSettingsFileInstallerTests.swift +++ b/supacodeTests/AgentHookSettingsFileInstallerTests.swift @@ -36,10 +36,10 @@ struct AgentHookSettingsFileInstallerTests { "type": "command", "command": .string(AgentHookSettingsCommand.busyCommand(active: false)), "timeout": 10, - ]), - ]), - ]), - ], + ]) + ]) + ]) + ] ] } @@ -113,11 +113,11 @@ struct AgentHookSettingsFileInstallerTests { .object([ "type": "command", "command": "SUPACODE_CLI_PATH agent-hook --stop", - ]), - ]), - ]), - ]), - ]), + ]) + ]) + ]) + ]) + ]) ]) try fileManager.createDirectory( at: url.deletingLastPathComponent(), @@ -163,8 +163,8 @@ struct AgentHookSettingsFileInstallerTests { .object([ "type": "command", "command": "echo third-party", - ]), - ]), + ]) + ]) ])) hooks["Stop"] = .array(stopGroups) root["hooks"] = .object(hooks) diff --git a/supacodeTests/AppFeatureArchivedSelectionTests.swift b/supacodeTests/AppFeatureArchivedSelectionTests.swift index 338d625fd..6eb9634a5 100644 --- a/supacodeTests/AppFeatureArchivedSelectionTests.swift +++ b/supacodeTests/AppFeatureArchivedSelectionTests.swift @@ -5,6 +5,7 @@ import IdentifiedCollections import Testing @testable import SupacodeSettingsFeature +@testable import SupacodeSettingsShared @testable import supacode @MainActor @@ -79,7 +80,11 @@ struct AppFeatureArchivedSelectionTests { repositories: repositoriesState, settings: SettingsFeature.State() ) - appState.repositories.runScriptWorktreeIDs = [activeWorktree.id, archivedWorktree.id] + let scriptID = UUID() + appState.repositories.runningScriptsByWorktreeID = [ + activeWorktree.id: [scriptID], + archivedWorktree.id: [scriptID], + ] let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: appState) { AppFeature() @@ -92,7 +97,7 @@ struct AppFeatureArchivedSelectionTests { store.exhaustivity = .off await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) { - $0.repositories.runScriptWorktreeIDs = [activeWorktree.id] + $0.repositories.runningScriptsByWorktreeID = [activeWorktree.id: [scriptID]] } await store.finish() diff --git a/supacodeTests/AppFeatureDeeplinkTests.swift b/supacodeTests/AppFeatureDeeplinkTests.swift index 5c1a2a7f7..c5691c38f 100644 --- a/supacodeTests/AppFeatureDeeplinkTests.swift +++ b/supacodeTests/AppFeatureDeeplinkTests.swift @@ -165,7 +165,7 @@ struct AppFeatureDeeplinkTests { await store.send(.deeplink(.worktree(id: worktree.id, action: .stop))) await store.receive(\.repositories.selectWorktree) - await store.receive(\.stopRunScript) + await store.receive(\.stopRunScripts) } // MARK: - Help deeplink. diff --git a/supacodeTests/AppFeatureDefaultEditorTests.swift b/supacodeTests/AppFeatureDefaultEditorTests.swift index ef86976ee..3c64cd9ba 100644 --- a/supacodeTests/AppFeatureDefaultEditorTests.swift +++ b/supacodeTests/AppFeatureDefaultEditorTests.swift @@ -37,7 +37,7 @@ struct AppFeatureDefaultEditorTests { await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) await store.receive(\.worktreeSettingsLoaded) #expect(store.state.openActionSelection == .finder) - #expect(store.state.selectedRunScript == "") + #expect(store.state.scripts.isEmpty) await store.finish() } @@ -52,9 +52,15 @@ struct AppFeatureDefaultEditorTests { let repositoryID = worktree.repositoryRootURL.standardizedFileURL.path(percentEncoded: false) var globalRepositorySettings = RepositorySettings.default globalRepositorySettings.openActionID = OpenWorktreeAction.finder.settingsID - var localRepositorySettings = RepositorySettings.default - localRepositorySettings.openActionID = OpenWorktreeAction.terminal.settingsID - localRepositorySettings.runScript = "pnpm dev" + var localRepositorySettings = RepositorySettings( + setupScript: "", + archiveScript: "", + deleteScript: "", + runScript: "pnpm dev", + scripts: [ScriptDefinition(kind: .run, command: "pnpm dev")], + openActionID: OpenWorktreeAction.terminal.settingsID, + worktreeBaseRef: nil + ) withDependencies { $0.settingsFileStorage = settingsStorage.storage @@ -90,7 +96,7 @@ struct AppFeatureDefaultEditorTests { await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) await store.receive(\.worktreeSettingsLoaded) { $0.openActionSelection = .terminal - $0.selectedRunScript = "pnpm dev" + $0.scripts = localRepositorySettings.scripts } await store.finish() } diff --git a/supacodeTests/AppFeatureOpenWorktreeTests.swift b/supacodeTests/AppFeatureOpenWorktreeTests.swift index 674798c12..664336f39 100644 --- a/supacodeTests/AppFeatureOpenWorktreeTests.swift +++ b/supacodeTests/AppFeatureOpenWorktreeTests.swift @@ -36,7 +36,7 @@ struct AppFeatureOpenWorktreeTests { #expect(context.openedActions.value.isEmpty) #expect( context.terminalCommands.value == [ - .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: false), + .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: false) ] ) await store.finish() @@ -49,7 +49,7 @@ struct AppFeatureOpenWorktreeTests { await store.receive(\.repositories.delegate.openWorktreeInApp) #expect( context.terminalCommands.value == [ - .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: true), + .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: true) ] ) await store.finish() diff --git a/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index e51334bb5..881552285 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -10,77 +10,211 @@ import Testing @MainActor struct AppFeatureRunScriptTests { - @Test(.dependencies) func runScriptWithoutConfiguredScriptPresentsPrompt() async { + @Test(.dependencies) func runScriptWithoutConfiguredScriptsOpensSettings() async { let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) + let expectedRepositoryID = worktree.repositoryRootURL.path(percentEncoded: false) + var settingsState = SettingsFeature.State() + settingsState.repositorySummaries = [ + SettingsRepositorySummary(id: expectedRepositoryID, name: "repo") + ] let store = TestStore( initialState: AppFeature.State( repositories: repositories, - settings: SettingsFeature.State() + settings: settingsState ) ) { AppFeature() } + store.exhaustivity = .off - await store.send(.runScript) { - $0.runScriptDraft = "" - $0.isRunScriptPromptPresented = true - } + await store.send(.runScript) + await store.receive(\.settings.setSelection) + #expect(store.state.settings.selection == .repositoryScripts(expectedRepositoryID)) } - @Test(.dependencies) func saveRunScriptAndRunPersistsAndExecutesScript() async { + @Test(.dependencies) func runScriptRunsFirstRunKindScript() async { let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) - let storage = SettingsTestStorage() + let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") let sent = LockIsolated<[TerminalClient.Command]>([]) - let store = withDependencies { - $0.settingsFileStorage = storage.storage - } operation: { - TestStore( - initialState: AppFeature.State( - repositories: repositories, - settings: SettingsFeature.State() - ) - ) { - AppFeature() - } withDependencies: { - $0.terminalClient.send = { command in - sent.withValue { $0.append(command) } - } + var initialState = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + initialState.scripts = [definition] + let store = TestStore(initialState: initialState) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } } } - await store.send(.runScript) { - $0.runScriptDraft = "" - $0.isRunScriptPromptPresented = true + await store.send(.runScript) + await store.receive(\.runNamedScript) { + $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + + } + await store.finish() + + #expect(sent.value.count == 1) + guard case .runBlockingScript(let sentWorktree, let kind, let script) = sent.value.first else { + Issue.record("Expected runBlockingScript command") + return + } + #expect(sentWorktree == worktree) + #expect(script == "npm run dev") + guard case .script(let sentDefinition) = kind else { + Issue.record("Expected .script kind") + return + } + #expect(sentDefinition.kind == .run) + #expect(sentDefinition.command == "npm run dev") + } + + @Test(.dependencies) func runNamedScriptTracksRunningState() async { + let worktree = makeWorktree() + let repositories = makeRepositoriesState(worktree: worktree) + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") + var initialState = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + initialState.scripts = [definition] + let store = TestStore(initialState: initialState) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { _ in } } - await store.send(.runScriptDraftChanged("npm run dev")) { - $0.runScriptDraft = "npm run dev" + + await store.send(.runNamedScript(definition)) { + $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] } - await store.send(.saveRunScriptAndRun) { - $0.selectedRunScript = "npm run dev" - $0.runScriptDraft = "" - $0.isRunScriptPromptPresented = false + await store.finish() + } + + @Test(.dependencies) func runNamedScriptRejectsDuplicateRun() async { + let worktree = makeWorktree() + let repositories = makeRepositoriesState(worktree: worktree) + let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") + var initialState = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + initialState.scripts = [definition] + // Pre-populate running state to simulate an already-running script. + initialState.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: initialState) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + // Second run of the same script should be silently rejected. + await store.send(.runNamedScript(definition)) + #expect(sent.value.isEmpty) + } + + @Test(.dependencies) func scriptCompletedRemovesFromTracking() async { + let worktree = makeWorktree() + let repositories = makeRepositoriesState(worktree: worktree) + let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") + var repositoriesState = repositories + repositoriesState.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } + + await store.send( + .repositories( + .scriptCompleted( + worktreeID: worktree.id, + scriptID: definition.id, + kind: .script(definition), + exitCode: 0, + tabId: nil + ) + ) + ) { + $0.repositories.runningScriptsByWorktreeID = [:] + } - await store.receive(\.runScript) { - $0.repositories.runScriptWorktreeIDs = [worktree.id] + } + + @Test(.dependencies) func stopRunScriptsCallsTerminalClient() async { + let worktree = makeWorktree() + let repositories = makeRepositoriesState(worktree: worktree) + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } } + + await store.send(.stopRunScripts) await store.finish() - #expect(sent.value == [.runBlockingScript(worktree, kind: .run, script: "npm run dev")]) + #expect(sent.value.count == 1) + guard case .stopRunScript(let sentWorktree) = sent.value.first else { + Issue.record("Expected stopRunScript command") + return + } + #expect(sentWorktree == worktree) + } - let savedRunScript = withDependencies { - $0.settingsFileStorage = storage.storage - } operation: { - @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings - return repositorySettings.runScript + @Test(.dependencies) func stopScriptSendsTerminalCommand() async { + let worktree = makeWorktree() + let repositories = makeRepositoriesState(worktree: worktree) + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State(), + ), + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.stopScript(definition)) + await store.finish() + + #expect(sent.value.count == 1) + guard case .stopScript(let sentWorktree, let definitionID) = sent.value.first else { + Issue.record("Expected stopScript command") + return } - #expect(savedRunScript == "npm run dev") + #expect(sentWorktree == worktree) + #expect(definitionID == definition.id) } - @Test(.dependencies) func runScriptDoesNotOverwriteDraftWhenPromptAlreadyPresented() async { + @Test(.dependencies) func worktreeSettingsLoadedPopulatesScripts() async { let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) + let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") + var settings = RepositorySettings.default + settings.scripts = [definition] let store = TestStore( initialState: AppFeature.State( repositories: repositories, @@ -89,17 +223,46 @@ struct AppFeatureRunScriptTests { ) { AppFeature() } + store.exhaustivity = .off - await store.send(.runScript) { - $0.runScriptDraft = "" - $0.isRunScriptPromptPresented = true + await store.send(.worktreeSettingsLoaded(settings, worktreeID: worktree.id)) + #expect(store.state.scripts == [definition]) + } + + @Test(.dependencies) func scriptCompletedCleansUpOrphanedIDAfterScriptDeletion() async { + let worktree = makeWorktree() + let repositories = makeRepositoriesState(worktree: worktree) + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") + // Simulate a script that is running but has been removed from + // the settings (e.g. user deleted it while it was executing). + var repositoriesState = repositories + repositoriesState.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() } - await store.send(.runScriptDraftChanged("pnpm dev")) { - $0.runScriptDraft = "pnpm dev" + // Scripts array is empty — the definition was deleted from settings. + #expect(store.state.scripts.isEmpty) + + await store.send( + .repositories( + .scriptCompleted( + worktreeID: worktree.id, + scriptID: definition.id, + kind: .script(definition), + exitCode: 0, + tabId: nil + ) + ) + ) { + $0.repositories.runningScriptsByWorktreeID = [:] + } - await store.send(.runScript) - #expect(store.state.runScriptDraft == "pnpm dev") - #expect(store.state.isRunScriptPromptPresented) } private func makeWorktree() -> Worktree { diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 54eefb41c..8281be385 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import CustomDump import Foundation import IdentifiedCollections +import SupacodeSettingsShared import Testing @testable import supacode @@ -49,7 +50,7 @@ struct CommandPaletteFeatureTests { copyIgnored: false, copyUntracked: false ) - ), + ) ] let items = CommandPaletteFeature.commandPaletteItems(from: state) @@ -74,7 +75,7 @@ struct CommandPaletteFeatureTests { description: "Focus the split to the right.", action: "goto_split:right", actionKey: "goto_split" - ), + ) ] ) @@ -98,7 +99,7 @@ struct CommandPaletteFeatureTests { description: "", action: "goto_split:right", actionKey: "goto_split" - ), + ) ] ) @@ -1001,6 +1002,92 @@ struct CommandPaletteFeatureTests { } await store.receive(.delegate(.ghosttyCommand("goto_split:right"))) } + + // MARK: - Script items. + + @Test func commandPaletteItems_includesRunItemsForConfiguredScripts() { + let rootPath = "/tmp/repo" + let worktree = makeWorktree(id: rootPath, name: "repo", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let runDef = ScriptDefinition(kind: .run, command: "npm run dev") + let testDef = ScriptDefinition(kind: .test, command: "npm test") + + let items = CommandPaletteFeature.commandPaletteItems( + from: state, + scripts: [runDef, testDef] + ) + + let runItem = items.first { $0.id == "script.\(runDef.id).run" } + let testItem = items.first { $0.id == "script.\(testDef.id).run" } + #expect(runItem?.title == "Run: Run") + #expect(testItem?.title == "Run: Test") + } + + @Test func commandPaletteItems_showsStopForRunningScripts() { + let rootPath = "/tmp/repo" + let worktree = makeWorktree(id: rootPath, name: "repo", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let definition = ScriptDefinition(kind: .run, command: "npm run dev") + + let items = CommandPaletteFeature.commandPaletteItems( + from: state, + scripts: [definition], + runningScriptIDs: [definition.id] + ) + + let stopItem = items.first { $0.id == "script.\(definition.id).stop" } + #expect(stopItem?.title == "Stop: Run") + #expect(stopItem?.priorityTier == 0) + // No run item should exist for a running script. + let runItem = items.first { $0.id == "script.\(definition.id).run" } + #expect(runItem == nil) + } + + @Test func commandPaletteItems_excludesEmptyCommandScripts() { + let rootPath = "/tmp/repo" + let worktree = makeWorktree(id: rootPath, name: "repo", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let emptyDef = ScriptDefinition(kind: .run, command: " ") + let validDef = ScriptDefinition(kind: .test, command: "npm test") + + let items = CommandPaletteFeature.commandPaletteItems( + from: state, + scripts: [emptyDef, validDef] + ) + + #expect(items.contains { $0.id == "script.\(emptyDef.id).run" } == false) + #expect(items.contains { $0.id == "script.\(validDef.id).run" }) + } + + @Test func commandPaletteItems_excludesScriptsWithoutSelectedWorktree() { + let definition = ScriptDefinition(kind: .run, command: "npm run dev") + let items = CommandPaletteFeature.commandPaletteItems( + from: RepositoriesFeature.State(), + scripts: [definition] + ) + + #expect(items.contains { $0.id == "script.\(definition.id).run" } == false) + } + + @Test func recencyRetentionIDs_includesScriptIDs() { + let definition = ScriptDefinition(kind: .run, command: "npm run dev") + let ids = CommandPaletteFeature.recencyRetentionIDs( + from: [], + scripts: [definition] + ) + + #expect(ids.contains("script.\(definition.id).run")) + #expect(ids.contains("script.\(definition.id).stop")) + } } private func makeWorktree( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index b1f4bfe08..06253c6a4 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -884,7 +884,7 @@ struct RepositoriesFeatureTests { stage: .loadingLocalBranches, worktreeName: "feature/new-branch" ) - ), + ) ] $0.selection = SidebarSelection.worktree(pendingID) $0.sidebarSelectedWorktreeIDs = [pendingID] @@ -1505,7 +1505,7 @@ struct RepositoriesFeatureTests { id: pendingID, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .loadingLocalBranches) - ), + ) ] let store = TestStore(initialState: state) { RepositoriesFeature() @@ -1542,7 +1542,7 @@ struct RepositoriesFeatureTests { stage: .checkingRepositoryMode, worktreeName: "swift-otter" ) - ), + ) ] let store = TestStore(initialState: state) { RepositoriesFeature() @@ -1739,7 +1739,7 @@ struct RepositoriesFeatureTests { addedLines: nil, removedLines: nil, pullRequest: makePullRequest(state: "MERGED") - ), + ) ] let fixedDate = Date(timeIntervalSince1970: 1_000_000) let store = TestStore(initialState: state) { @@ -1784,20 +1784,31 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.runBlockingScript) } - @Test(.dependencies) func runScriptCompletedWithFailureShowsAlert() async { + @Test(.dependencies) func scriptCompletedWithFailureShowsAlert() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) + let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) - state.runScriptWorktreeIDs = [worktree.id] + state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + let store = TestStore(initialState: state) { RepositoriesFeature() } - await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: 1, tabId: nil)) { - $0.runScriptWorktreeIDs = [] + await store.send( + .scriptCompleted( + worktreeID: worktree.id, + scriptID: definition.id, + kind: .script(definition), + exitCode: 1, + tabId: nil + ) + ) { + $0.runningScriptsByWorktreeID = [:] + $0.alert = expectedScriptFailureAlert( - kind: .run, + kind: .script(definition), exitMessage: "Script failed (exit code 1).", worktreeID: worktree.id, repoName: "repo", @@ -1806,34 +1817,56 @@ struct RepositoriesFeatureTests { } } - @Test(.dependencies) func runScriptCompletedWithSuccessDoesNotShowAlert() async { + @Test(.dependencies) func scriptCompletedWithSuccessDoesNotShowAlert() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) + let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) - state.runScriptWorktreeIDs = [worktree.id] + state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + let store = TestStore(initialState: state) { RepositoriesFeature() } - await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: 0, tabId: nil)) { - $0.runScriptWorktreeIDs = [] + await store.send( + .scriptCompleted( + worktreeID: worktree.id, + scriptID: definition.id, + kind: .script(definition), + exitCode: 0, + tabId: nil + ) + ) { + $0.runningScriptsByWorktreeID = [:] + } #expect(store.state.alert == nil) } - @Test(.dependencies) func runScriptCompletedWithNilExitCodeDoesNotShowAlert() async { + @Test(.dependencies) func scriptCompletedWithNilExitCodeDoesNotShowAlert() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) + let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) - state.runScriptWorktreeIDs = [worktree.id] + state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + let store = TestStore(initialState: state) { RepositoriesFeature() } - await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: nil, tabId: nil)) { - $0.runScriptWorktreeIDs = [] + await store.send( + .scriptCompleted( + worktreeID: worktree.id, + scriptID: definition.id, + kind: .script(definition), + exitCode: nil, + tabId: nil + ) + ) { + $0.runningScriptsByWorktreeID = [:] + } #expect(store.state.alert == nil) } @@ -1844,18 +1877,29 @@ struct RepositoriesFeatureTests { let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) let tabId = TerminalTabID() + let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) - state.runScriptWorktreeIDs = [worktree.id] + state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + let store = TestStore(initialState: state) { RepositoriesFeature() } store.exhaustivity = .off // Trigger the failure alert through the normal flow. - await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: 1, tabId: tabId)) { - $0.runScriptWorktreeIDs = [] + await store.send( + .scriptCompleted( + worktreeID: worktree.id, + scriptID: definition.id, + kind: .script(definition), + exitCode: 1, + tabId: tabId + ) + ) { + $0.runningScriptsByWorktreeID = [:] + $0.alert = expectedScriptFailureAlert( - kind: .run, + kind: .script(definition), exitMessage: "Script failed (exit code 1).", worktreeID: worktree.id, tabId: tabId, @@ -2943,7 +2987,7 @@ struct RepositoriesFeatureTests { id: removedWorktree.id, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .choosingWorktreeName) - ), + ) ] initialState.pinnedWorktreeIDs = [removedWorktree.id] initialState.worktreeInfoByID = [ @@ -3026,7 +3070,7 @@ struct RepositoriesFeatureTests { id: pendingID, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .loadingLocalBranches) - ), + ) ] initialState.selection = .worktree(pendingID) initialState.sidebarSelectedWorktreeIDs = [existingWorktree.id, pendingID] diff --git a/supacodeTests/RepositorySettingsKeyTests.swift b/supacodeTests/RepositorySettingsKeyTests.swift index 65a6031d2..5a56220cb 100644 --- a/supacodeTests/RepositorySettingsKeyTests.swift +++ b/supacodeTests/RepositorySettingsKeyTests.swift @@ -49,7 +49,7 @@ struct RepositorySettingsKeyTests { let rootURL = URL(fileURLWithPath: "/tmp/repo") var updated = RepositorySettings.default - updated.runScript = "echo updated" + updated.setupScript = "echo updated" withDependencies { $0.settingsFileStorage = storage.storage @@ -146,9 +146,9 @@ struct RepositorySettingsKeyTests { let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) var globalSettings = RepositorySettings.default - globalSettings.runScript = "echo global" + globalSettings.setupScript = "echo global" var localSettings = RepositorySettings.default - localSettings.runScript = "echo local" + localSettings.setupScript = "echo local" withDependencies { $0.settingsFileStorage = globalStorage.storage @@ -185,7 +185,7 @@ struct RepositorySettingsKeyTests { let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) var globalSettings = RepositorySettings.default - globalSettings.runScript = "echo global" + globalSettings.setupScript = "echo global" withDependencies { $0.settingsFileStorage = globalStorage.storage @@ -218,7 +218,7 @@ struct RepositorySettingsKeyTests { let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) var globalSettings = RepositorySettings.default - globalSettings.runScript = "echo global" + globalSettings.setupScript = "echo global" withDependencies { $0.settingsFileStorage = globalStorage.storage @@ -256,7 +256,7 @@ struct RepositorySettingsKeyTests { try localStorage.save(encode(.default), at: localURL) var updated = RepositorySettings.default - updated.runScript = "echo local" + updated.setupScript = "echo local" withDependencies { $0.settingsFileStorage = globalStorage.storage @@ -294,7 +294,7 @@ struct RepositorySettingsKeyTests { let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) var updated = RepositorySettings.default - updated.runScript = "echo global" + updated.setupScript = "echo global" withDependencies { $0.settingsFileStorage = globalStorage.storage diff --git a/supacodeTests/RepositorySettingsScriptTests.swift b/supacodeTests/RepositorySettingsScriptTests.swift new file mode 100644 index 000000000..c592eb257 --- /dev/null +++ b/supacodeTests/RepositorySettingsScriptTests.swift @@ -0,0 +1,258 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import SupacodeSettingsFeature +@testable import SupacodeSettingsShared + +// MARK: - Codable migration tests. + +struct RepositorySettingsCodableTests { + @Test func decodeFromLegacyRunScriptOnly() throws { + // JSON with only `runScript` and no `scripts` key should produce + // a single `.run`-kind ScriptDefinition. + let json = """ + { + "setupScript": "", + "archiveScript": "", + "deleteScript": "", + "runScript": "npm start", + "openActionID": "automatic" + } + """ + let data = Data(json.utf8) + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) + #expect(settings.scripts.count == 1) + #expect(settings.scripts.first?.kind == .run) + #expect(settings.scripts.first?.command == "npm start") + } + + @Test func decodeWithBothRunScriptAndScripts() throws { + // When both `runScript` and `scripts` are present, `scripts` wins. + let json = """ + { + "setupScript": "", + "archiveScript": "", + "deleteScript": "", + "runScript": "legacy command", + "scripts": [ + { + "id": "00000000-0000-0000-0000-000000000001", + "kind": "test", "name": "Test", + "systemImage": "checkmark.diamond.fill", + "tintColor": "blue", "command": "npm test" + } + ], + "openActionID": "automatic" + } + """ + let data = Data(json.utf8) + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) + #expect(settings.scripts.count == 1) + #expect(settings.scripts.first?.kind == .test) + #expect(settings.scripts.first?.command == "npm test") + } + + @Test func encodeRoundTripPopulatesRunScript() throws { + // Encoding settings with scripts should derive `runScript` from + // the first `.run`-kind script's command. + var settings = RepositorySettings.default + settings.scripts = [ + ScriptDefinition(kind: .test, command: "npm test"), + ScriptDefinition(kind: .run, command: "npm run dev"), + ] + let data = try JSONEncoder().encode(settings) + let raw = try JSONDecoder().decode([String: AnyCodable].self, from: data) + #expect(raw["runScript"]?.stringValue == "npm run dev") + } + + @Test func encodeWithNoRunKindScriptClearsRunScript() throws { + // When no `.run`-kind script exists, the encoded `runScript` + // should be empty — not the stale legacy value. + var settings = RepositorySettings( + setupScript: "", + archiveScript: "", + deleteScript: "", + runScript: "stale legacy command", + scripts: [ScriptDefinition(kind: .test, command: "npm test")], + openActionID: "automatic", + worktreeBaseRef: nil + ) + let data = try JSONEncoder().encode(settings) + let raw = try JSONDecoder().decode([String: AnyCodable].self, from: data) + #expect(raw["runScript"]?.stringValue == "") + } + + @Test func decodeWithUnknownScriptKindDropsOnlyInvalidEntries() throws { + // An unknown `kind` value should only drop that entry, not the + // entire array. Valid sibling scripts must survive. + let json = """ + { + "setupScript": "", + "archiveScript": "", + "deleteScript": "", + "runScript": "", + "scripts": [ + { + "id": "00000000-0000-0000-0000-000000000001", + "kind": "run", "name": "Run", + "systemImage": "play", + "tintColor": "green", "command": "npm start" + }, + { + "id": "00000000-0000-0000-0000-000000000002", + "kind": "unknown_future_kind", "name": "X", + "systemImage": "star", + "tintColor": "red", "command": "echo hi" + }, + { + "id": "00000000-0000-0000-0000-000000000003", + "kind": "test", "name": "Test", + "systemImage": "play.diamond", + "tintColor": "yellow", "command": "npm test" + } + ], + "openActionID": "automatic" + } + """ + let data = Data(json.utf8) + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) + #expect(settings.scripts.count == 2) + #expect(settings.scripts[0].kind == .run) + #expect(settings.scripts[0].command == "npm start") + #expect(settings.scripts[1].kind == .test) + #expect(settings.scripts[1].command == "npm test") + } +} + +/// Lightweight type-erased wrapper for JSON inspection in tests. +private struct AnyCodable: Decodable { + let value: Any + + var stringValue: String? { value as? String } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + value = string + } else if let int = try? container.decode(Int.self) { + value = int + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map(\.value) + } else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues(\.value) + } else { + value = NSNull() + } + } +} + +// MARK: - Feature tests. + +@MainActor +struct RepositorySettingsScriptTests { + private static let rootURL = URL(filePath: "/tmp/test-repo") + + private func makeStore( + scripts: [ScriptDefinition] = [] + ) -> TestStore { + var settings = RepositorySettings.default + settings.scripts = scripts + return TestStore( + initialState: RepositorySettingsFeature.State( + rootURL: Self.rootURL, + settings: settings, + ), + ) { + RepositorySettingsFeature() + } + } + + @Test(.dependencies) func addScriptAppendsCustomScript() async { + let store = makeStore() + store.exhaustivity = .off(showSkippedAssertions: false) + + await store.send(.addScript(.custom)) { + #expect($0.settings.scripts.count == 1) + #expect($0.settings.scripts.first?.kind == .custom) + #expect($0.settings.scripts.first?.name == "Custom") + } + } + + @Test(.dependencies) func addScriptRejectsDuplicatePredefinedKind() async { + let store = makeStore(scripts: [ScriptDefinition(kind: .lint, command: "swiftlint")]) + store.exhaustivity = .off(showSkippedAssertions: false) + + // Second .lint is silently rejected. + await store.send(.addScript(.lint)) + #expect(store.state.settings.scripts.count == 1) + } + + @Test(.dependencies) func addScriptAllowsMultipleCustomKinds() async { + let store = makeStore(scripts: [ScriptDefinition(kind: .custom, name: "A", command: "a")]) + store.exhaustivity = .off(showSkippedAssertions: false) + + await store.send(.addScript(.custom)) { + #expect($0.settings.scripts.count == 2) + } + } + + @Test(.dependencies) func removeScriptShowsConfirmationAndRemovesByID() async { + let script1 = ScriptDefinition(kind: .run, command: "npm run dev") + let script2 = ScriptDefinition(kind: .test, command: "npm test") + let script3 = ScriptDefinition(kind: .deploy, command: "deploy.sh") + let store = makeStore(scripts: [script1, script2, script3]) + store.exhaustivity = .off(showSkippedAssertions: false) + + await store.send(.removeScript(script2.id)) { + $0.alert = AlertState { + TextState("Remove \"\(script2.displayName)\" script?") + } actions: { + ButtonState(role: .destructive, action: .confirmRemoveScript(script2.id)) { + TextState("Remove") + } + ButtonState(role: .cancel) { + TextState("Cancel") + } + } message: { + TextState("This action cannot be undone.") + } + } + + await store.send(.alert(.presented(.confirmRemoveScript(script2.id)))) { + $0.alert = nil + $0.settings.scripts = [script1, script3] + } + } + + @Test(.dependencies) func removeScriptCancelDoesNotRemove() async { + let script = ScriptDefinition(kind: .run, command: "npm run dev") + let store = makeStore(scripts: [script]) + store.exhaustivity = .off(showSkippedAssertions: false) + + await store.send(.removeScript(script.id)) { + $0.alert = AlertState { + TextState("Remove \"\(script.displayName)\" script?") + } actions: { + ButtonState(role: .destructive, action: .confirmRemoveScript(script.id)) { + TextState("Remove") + } + ButtonState(role: .cancel) { + TextState("Cancel") + } + } message: { + TextState("This action cannot be undone.") + } + } + + await store.send(.alert(.dismiss)) { + $0.alert = nil + } + + #expect(store.state.settings.scripts.count == 1) + } + +} diff --git a/supacodeTests/TerminalLayoutSnapshotTests.swift b/supacodeTests/TerminalLayoutSnapshotTests.swift index 37f19ee02..7221c0055 100644 --- a/supacodeTests/TerminalLayoutSnapshotTests.swift +++ b/supacodeTests/TerminalLayoutSnapshotTests.swift @@ -89,7 +89,7 @@ struct TerminalLayoutSnapshotTests { tintColor: nil, layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/home")), focusedLeafIndex: 0 - ), + ) ], selectedTabIndex: 0 ) diff --git a/supacodeTests/TerminalTabManagerTests.swift b/supacodeTests/TerminalTabManagerTests.swift index b74706df1..98cdbba98 100644 --- a/supacodeTests/TerminalTabManagerTests.swift +++ b/supacodeTests/TerminalTabManagerTests.swift @@ -1,3 +1,4 @@ +import SupacodeSettingsShared import Testing @testable import supacode diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index c475af3aa..881a7ce99 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -1,5 +1,6 @@ import Dependencies import Foundation +import SupacodeSettingsShared import Testing @testable import supacode @@ -166,7 +167,7 @@ struct WorktreeTerminalManagerTests { title: "Unread", body: "body", isRead: false - ), + ) ] state.onNotificationIndicatorChanged?() state.notifications = [ @@ -175,7 +176,7 @@ struct WorktreeTerminalManagerTests { title: "Read", body: "body", isRead: true - ), + ) ] let stream = manager.eventStream() @@ -588,31 +589,34 @@ struct WorktreeTerminalManagerTests { @Test func runScriptBlockingScriptTracksRunningState() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() + let definition = ScriptDefinition(kind: .run, command: "echo hi") - #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == false) + #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == false) - manager.handleCommand(.runBlockingScript(worktree, kind: .run, script: "echo hi")) + manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "echo hi")) - #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == true) + #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == true) } @Test func stopRunScriptClosesRunTab() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() + let definition = ScriptDefinition(kind: .run, command: "sleep 10") - manager.handleCommand(.runBlockingScript(worktree, kind: .run, script: "sleep 10")) - #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == true) + manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "sleep 10")) + #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == true) manager.handleCommand(.stopRunScript(worktree)) - #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == false) + #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == false) } @Test func runScriptTabTitleResetsAfterSignalInterruption() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let stream = manager.eventStream() + let definition = ScriptDefinition(kind: .run, command: "sleep 10") - manager.handleCommand(.runBlockingScript(worktree, kind: .run, script: "sleep 10")) + manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "sleep 10")) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -623,7 +627,7 @@ struct WorktreeTerminalManagerTests { } let tab = state.tabManager.tabs.first { $0.id == tabId } - #expect(tab?.title == "Run Script") + #expect(tab?.title == "Run") #expect(tab?.isTitleLocked == true) #expect(tab?.tintColor == .green) @@ -841,7 +845,7 @@ struct WorktreeTerminalManagerTests { ) ), focusedLeafIndex: 0 - ), + ) ], selectedTabIndex: 0 )