From 464e5a6630f3e66a898d994d6ceda62e56483d2f Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Tue, 14 Apr 2026 23:38:23 +0200 Subject: [PATCH 01/20] Add ScriptKind, ScriptDefinition, and refactor BlockingScriptKind Introduce the foundational data model for multiple running scripts: - ScriptKind enum with predefined types (run, debug, test, deploy, custom), each carrying default icon, color, and name. - ScriptDefinition struct with id, kind, name, systemImage, tintColor, and command fields. - Move TerminalTabTintColor to SupacodeSettingsShared and extend with blue, purple, yellow, and teal cases for new script types. - Refactor BlockingScriptKind: replace .run with .script(ScriptDefinition) to support multiple concurrent user-defined scripts while keeping .archive and .delete as lifecycle-bound cases. - Add scripts and selectedScriptID fields to RepositorySettings with backward-compatible migration from the legacy runScript field. - Update all downstream switch sites, terminal state tracking, and tests. --- .../Models/RepositorySettings.swift | 23 ++++++++++ .../Models/ScriptDefinition.swift | 29 ++++++++++++ .../Models/ScriptKind.swift | 45 +++++++++++++++++++ .../Models/TerminalTabTintColor.swift | 11 +++++ .../Features/App/Reducer/AppFeature.swift | 5 ++- .../Reducer/RepositoriesFeature.swift | 6 ++- .../WorktreeTerminalManager.swift | 2 +- .../Terminal/Models/BlockingScriptKind.swift | 35 +++++++++++---- .../Models/TerminalLayoutSnapshot.swift | 1 + .../Terminal/Models/TerminalTabItem.swift | 1 + .../Models/TerminalTabTintColor.swift | 13 +++--- .../Models/WorktreeTerminalState.swift | 28 +++++++++++- .../TabBar/Views/TerminalTabBackground.swift | 1 + supacodeTests/AppFeatureRunScriptTests.swift | 14 +++++- supacodeTests/RepositoriesFeatureTests.swift | 4 +- supacodeTests/TerminalTabManagerTests.swift | 1 + .../WorktreeTerminalManagerTests.swift | 20 +++++---- 17 files changed, 207 insertions(+), 32 deletions(-) create mode 100644 SupacodeSettingsShared/Models/ScriptDefinition.swift create mode 100644 SupacodeSettingsShared/Models/ScriptKind.swift create mode 100644 SupacodeSettingsShared/Models/TerminalTabTintColor.swift diff --git a/SupacodeSettingsShared/Models/RepositorySettings.swift b/SupacodeSettingsShared/Models/RepositorySettings.swift index 13d83154f..c54d3b9ac 100644 --- a/SupacodeSettingsShared/Models/RepositorySettings.swift +++ b/SupacodeSettingsShared/Models/RepositorySettings.swift @@ -5,6 +5,8 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { public var archiveScript: String public var deleteScript: String public var runScript: String + public var scripts: [ScriptDefinition] + public var selectedScriptID: UUID? public var openActionID: String public var worktreeBaseRef: String? public var worktreeBaseDirectoryPath: String? @@ -17,6 +19,8 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { case archiveScript case deleteScript case runScript + case scripts + case selectedScriptID case openActionID case worktreeBaseRef case worktreeBaseDirectoryPath @@ -30,6 +34,8 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { archiveScript: "", deleteScript: "", runScript: "", + scripts: [], + selectedScriptID: nil, openActionID: OpenWorktreeAction.automaticSettingsID, worktreeBaseRef: nil, worktreeBaseDirectoryPath: nil, @@ -43,6 +49,8 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { archiveScript: String, deleteScript: String, runScript: String, + scripts: [ScriptDefinition] = [], + selectedScriptID: UUID? = nil, openActionID: String, worktreeBaseRef: String?, worktreeBaseDirectoryPath: String? = nil, @@ -54,6 +62,8 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { self.archiveScript = archiveScript self.deleteScript = deleteScript self.runScript = runScript + self.scripts = scripts + self.selectedScriptID = selectedScriptID self.openActionID = openActionID self.worktreeBaseRef = worktreeBaseRef self.worktreeBaseDirectoryPath = worktreeBaseDirectoryPath @@ -76,6 +86,19 @@ 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. + let decodedScripts = try container.decodeIfPresent([ScriptDefinition].self, forKey: .scripts) + if let decodedScripts { + scripts = decodedScripts + } else if !runScript.isEmpty { + scripts = [ScriptDefinition(kind: .run, command: runScript)] + } else { + scripts = Self.default.scripts + } + selectedScriptID = + try container.decodeIfPresent(UUID.self, forKey: .selectedScriptID) + ?? Self.default.selectedScriptID openActionID = try container.decodeIfPresent(String.self, forKey: .openActionID) ?? Self.default.openActionID diff --git a/SupacodeSettingsShared/Models/ScriptDefinition.swift b/SupacodeSettingsShared/Models/ScriptDefinition.swift new file mode 100644 index 000000000..2dbdc9e5f --- /dev/null +++ b/SupacodeSettingsShared/Models/ScriptDefinition.swift @@ -0,0 +1,29 @@ +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 systemImage: String + public var tintColor: TerminalTabTintColor + public var command: String + + 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 ?? kind.defaultSystemImage + self.tintColor = tintColor ?? kind.defaultTintColor + self.command = command + } +} diff --git a/SupacodeSettingsShared/Models/ScriptKind.swift b/SupacodeSettingsShared/Models/ScriptKind.swift new file mode 100644 index 000000000..88a1b53b5 --- /dev/null +++ b/SupacodeSettingsShared/Models/ScriptKind.swift @@ -0,0 +1,45 @@ +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 debug + case test + case deploy + 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 .debug: "Debug" + case .test: "Test" + case .deploy: "Deploy" + case .custom: "Custom" + } + } + + /// Default SF Symbol name for the script kind. + public nonisolated var defaultSystemImage: String { + switch self { + case .run: "play.fill" + case .debug: "ant.fill" + case .test: "checkmark.diamond.fill" + case .deploy: "arrow.up.circle.fill" + case .custom: "terminal.fill" + } + } + + /// Default tab tint color for the script kind. + public nonisolated var defaultTintColor: TerminalTabTintColor { + switch self { + case .run: .green + case .debug: .orange + case .test: .blue + case .deploy: .purple + case .custom: .teal + } + } +} diff --git a/SupacodeSettingsShared/Models/TerminalTabTintColor.swift b/SupacodeSettingsShared/Models/TerminalTabTintColor.swift new file mode 100644 index 000000000..2f07daa59 --- /dev/null +++ b/SupacodeSettingsShared/Models/TerminalTabTintColor.swift @@ -0,0 +1,11 @@ +/// 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 +} diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 63d646b59..d9301e915 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -427,8 +427,9 @@ struct AppFeature { analyticsClient.capture("script_run", nil) state.repositories.runScriptWorktreeIDs.insert(worktree.id) let script = state.selectedRunScript + let definition = ScriptDefinition(kind: .run, command: script) return .run { _ in - await terminalClient.send(.runBlockingScript(worktree, kind: .run, script: script)) + await terminalClient.send(.runBlockingScript(worktree, kind: .script(definition), script: script)) } case .runScriptDraftChanged(let script): @@ -797,7 +798,7 @@ struct AppFeature { case .terminalEvent(.blockingScriptCompleted(let worktreeID, let kind, let exitCode, let tabId)): switch kind { - case .run: + case .script: return .send(.repositories(.runScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) case .archive: return .send(.repositories(.archiveScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index a29113062..39a35da90 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1399,7 +1399,11 @@ struct RepositoriesFeature { state.runScriptWorktreeIDs.remove(worktreeID) guard let exitCode, exitCode != 0 else { return .none } state.alert = blockingScriptFailureAlert( - kind: .run, exitCode: exitCode, worktreeID: worktreeID, tabId: tabId, state: state + kind: .script(ScriptDefinition(kind: .run)), + exitCode: exitCode, + worktreeID: worktreeID, + tabId: tabId, + state: state ) return .none diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 78c9c98e0..442301f23 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -122,7 +122,7 @@ 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 .runBlockingScript(let worktree, let kind, let script): _ = state(for: worktree).runBlockingScript(kind: kind, script) case .closeFocusedTab(let worktree): diff --git a/supacode/Features/Terminal/Models/BlockingScriptKind.swift b/supacode/Features/Terminal/Models/BlockingScriptKind.swift index 8dd488c67..41bb80fed 100644 --- a/supacode/Features/Terminal/Models/BlockingScriptKind.swift +++ b/supacode/Features/Terminal/Models/BlockingScriptKind.swift @@ -1,15 +1,18 @@ +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. +enum BlockingScriptKind: Hashable, Sendable { + case script(ScriptDefinition) case archive case delete var tabTitle: String { switch self { - case .run: "Run Script" + case .script(let definition): definition.name case .archive: "Archive Script" case .delete: "Delete Script" } @@ -17,7 +20,7 @@ enum BlockingScriptKind: Hashable, Sendable, CaseIterable { var tabIcon: String { switch self { - case .run: "play.fill" + case .script(let definition): definition.systemImage case .archive: "archivebox.fill" case .delete: "trash.fill" } @@ -25,9 +28,25 @@ enum BlockingScriptKind: Hashable, Sendable, CaseIterable { var tabColor: TerminalTabTintColor { switch self { - case .run: .green + case .script(let definition): definition.tintColor 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 Cmd+. + var isRunKind: Bool { + switch self { + case .script(let definition): definition.kind == .run + case .archive, .delete: false + } + } } 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 index a3c26e4ab..b7a16bde5 100644 --- a/supacode/Features/Terminal/Models/TerminalTabTintColor.swift +++ b/supacode/Features/Terminal/Models/TerminalTabTintColor.swift @@ -1,17 +1,16 @@ +import SupacodeSettingsShared 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 - +extension TerminalTabTintColor { 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 } } } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 35f9cd875..e3f0d4905 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -184,13 +184,37 @@ 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 (backward compat for Cmd+.). + @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/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index e51334bb5..a58344917 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -67,7 +67,19 @@ struct AppFeatureRunScriptTests { } await store.finish() - #expect(sent.value == [.runBlockingScript(worktree, kind: .run, script: "npm run dev")]) + #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 definition) = kind else { + Issue.record("Expected .script kind") + return + } + #expect(definition.kind == .run) + #expect(definition.command == "npm run dev") let savedRunScript = withDependencies { $0.settingsFileStorage = storage.storage diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index b1f4bfe08..d85a0d29a 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1797,7 +1797,7 @@ struct RepositoriesFeatureTests { await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: 1, tabId: nil)) { $0.runScriptWorktreeIDs = [] $0.alert = expectedScriptFailureAlert( - kind: .run, + kind: .script(ScriptDefinition(kind: .run)), exitMessage: "Script failed (exit code 1).", worktreeID: worktree.id, repoName: "repo", @@ -1855,7 +1855,7 @@ struct RepositoriesFeatureTests { await store.send(.runScriptCompleted(worktreeID: worktree.id, exitCode: 1, tabId: tabId)) { $0.runScriptWorktreeIDs = [] $0.alert = expectedScriptFailureAlert( - kind: .run, + kind: .script(ScriptDefinition(kind: .run)), exitMessage: "Script failed (exit code 1).", worktreeID: worktree.id, tabId: tabId, 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..e61e63666 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -1,5 +1,6 @@ import Dependencies import Foundation +import SupacodeSettingsShared import Testing @testable import supacode @@ -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) From 3a5927120c0ac901a31e0c09f35edf2a5074397f Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Tue, 14 Apr 2026 23:59:55 +0200 Subject: [PATCH 02/20] Add keyboard shortcuts for test, debug, and deploy scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register testScript (Cmd+Shift+U), debugScript (Cmd+Shift+Y), and deployScript (Cmd+Shift+D) in AppShortcutID with stable keys, display names, and Ghostty unbind support. Add corresponding menu items in WorktreeCommands and wire focused value actions through WorktreeDetailView. Actions are currently nil placeholders — they will be connected to AppFeature in a later commit. --- SupacodeSettingsShared/App/AppShortcuts.swift | 18 ++++++- supacode/Commands/WorktreeCommands.swift | 51 +++++++++++++++++++ .../Views/WorktreeDetailView.swift | 11 +++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/SupacodeSettingsShared/App/AppShortcuts.swift b/SupacodeSettingsShared/App/AppShortcuts.swift index 6d9b4d565..88818eefb 100644 --- a/SupacodeSettingsShared/App/AppShortcuts.swift +++ b/SupacodeSettingsShared/App/AppShortcuts.swift @@ -13,6 +13,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case selectWorktree(Int) case openFinder, openRepository, openPullRequest, copyPath case runScript, stopRunScript + case testScript, debugScript, deployScript // Stable string key for JSON dictionary persistence. public var codingKey: CodingKey { @@ -53,6 +54,9 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case .copyPath: "copyPath" case .runScript: "runScript" case .stopRunScript: "stopRunScript" + case .testScript: "testScript" + case .debugScript: "debugScript" + case .deployScript: "deployScript" } } @@ -76,6 +80,9 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep "copyPath": .copyPath, "runScript": .runScript, "stopRunScript": .stopRunScript, + "testScript": .testScript, + "debugScript": .debugScript, + "deployScript": .deployScript, ] private init?(stableKey: String) { @@ -112,6 +119,9 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case .copyPath: "Copy Path" case .runScript: "Run Script" case .stopRunScript: "Stop Run Script" + case .testScript: "Test Script" + case .debugScript: "Debug Script" + case .deployScript: "Deploy Script" } } } @@ -316,6 +326,9 @@ public enum AppShortcuts { public static let copyPath = AppShortcut(id: .copyPath, key: "c", modifiers: [.command, .shift]) public static let runScript = AppShortcut(id: .runScript, key: "r", modifiers: .command) public static let stopRunScript = AppShortcut(id: .stopRunScript, key: ".", modifiers: .command) + public static let testScript = AppShortcut(id: .testScript, key: "u", modifiers: [.command, .shift]) + public static let debugScript = AppShortcut(id: .debugScript, key: "y", modifiers: [.command, .shift]) + public static let deployScript = AppShortcut(id: .deployScript, key: "d", modifiers: [.command, .shift]) public static let worktreeSelection: [AppShortcut] = [ selectWorktree1, selectWorktree2, selectWorktree3, selectWorktree4, selectWorktree5, @@ -337,7 +350,10 @@ public enum AppShortcuts { AppShortcutGroup(category: .worktreeSelection, shortcuts: worktreeSelection), AppShortcutGroup( category: .actions, - shortcuts: [openFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript] + shortcuts: [ + openFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript, + testScript, debugScript, deployScript, + ] ), ] diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index db90d66ac..fba873f21 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -13,6 +13,9 @@ struct WorktreeCommands: Commands { @FocusedValue(\.deleteWorktreeAction) private var deleteWorktreeAction @FocusedValue(\.runScriptAction) private var runScriptAction @FocusedValue(\.stopRunScriptAction) private var stopRunScriptAction + @FocusedValue(\.testScriptAction) private var testScriptAction + @FocusedValue(\.debugScriptAction) private var debugScriptAction + @FocusedValue(\.deployScriptAction) private var deployScriptAction @FocusedValue(\.visibleHotkeyWorktreeRows) private var visibleHotkeyWorktreeRows init(store: StoreOf) { @@ -38,6 +41,9 @@ struct WorktreeCommands: Commands { let refresh = AppShortcuts.refreshWorktrees.effective(from: overrides) let run = AppShortcuts.runScript.effective(from: overrides) let stop = AppShortcuts.stopRunScript.effective(from: overrides) + let test = AppShortcuts.testScript.effective(from: overrides) + let debug = AppShortcuts.debugScript.effective(from: overrides) + let deploy = AppShortcuts.deployScript.effective(from: overrides) CommandMenu("Worktrees") { // Creation and opening. Button("New Worktree…", systemImage: "plus") { @@ -102,6 +108,24 @@ struct WorktreeCommands: Commands { .appKeyboardShortcut(stop) .help("Stop Script (\(stop?.display ?? "none"))") .disabled(stopRunScriptAction == nil) + Button("Test Script", systemImage: "testtube.2") { + testScriptAction?() + } + .appKeyboardShortcut(test) + .help("Test Script (\(test?.display ?? "none"))") + .disabled(testScriptAction == nil) + Button("Debug Script", systemImage: "ladybug") { + debugScriptAction?() + } + .appKeyboardShortcut(debug) + .help("Debug Script (\(debug?.display ?? "none"))") + .disabled(debugScriptAction == nil) + Button("Deploy Script", systemImage: "shippingbox") { + deployScriptAction?() + } + .appKeyboardShortcut(deploy) + .help("Deploy Script (\(deploy?.display ?? "none"))") + .disabled(deployScriptAction == nil) Divider() // Navigation. Button("Select Next", systemImage: "chevron.down") { @@ -231,6 +255,21 @@ extension FocusedValues { set { self[StopRunScriptActionKey.self] = newValue } } + var testScriptAction: (() -> Void)? { + get { self[TestScriptActionKey.self] } + set { self[TestScriptActionKey.self] = newValue } + } + + var debugScriptAction: (() -> Void)? { + get { self[DebugScriptActionKey.self] } + set { self[DebugScriptActionKey.self] = newValue } + } + + var deployScriptAction: (() -> Void)? { + get { self[DeployScriptActionKey.self] } + set { self[DeployScriptActionKey.self] = newValue } + } + var visibleHotkeyWorktreeRows: [WorktreeRowModel]? { get { self[VisibleHotkeyWorktreeRowsKey.self] } set { self[VisibleHotkeyWorktreeRowsKey.self] = newValue } @@ -245,6 +284,18 @@ private struct StopRunScriptActionKey: FocusedValueKey { typealias Value = () -> Void } +private struct TestScriptActionKey: FocusedValueKey { + typealias Value = () -> Void +} + +private struct DebugScriptActionKey: FocusedValueKey { + typealias Value = () -> Void +} + +private struct DeployScriptActionKey: FocusedValueKey { + typealias Value = () -> Void +} + private struct VisibleHotkeyWorktreeRowsKey: FocusedValueKey { typealias Value = [WorktreeRowModel] } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index cdcc6fa9e..ec0816c7d 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -217,6 +217,9 @@ struct WorktreeDetailView: View { .focusedSceneValue(\.endSearchAction, actions.endSearch) .focusedSceneValue(\.runScriptAction, actions.runScript) .focusedSceneValue(\.stopRunScriptAction, actions.stopRunScript) + .focusedSceneValue(\.testScriptAction, actions.testScript) + .focusedSceneValue(\.debugScriptAction, actions.debugScript) + .focusedSceneValue(\.deployScriptAction, actions.deployScript) } private func makeFocusedActions( @@ -238,7 +241,10 @@ struct WorktreeDetailView: View { navigateSearchPrevious: action(.navigateSearchPrevious), endSearch: action(.endSearch), runScript: runScriptEnabled ? { store.send(.runScript) } : nil, - stopRunScript: runScriptIsRunning ? { store.send(.stopRunScript) } : nil + stopRunScript: runScriptIsRunning ? { store.send(.stopRunScript) } : nil, + testScript: nil, + debugScript: nil, + deployScript: nil, ) } @@ -272,6 +278,9 @@ struct WorktreeDetailView: View { let endSearch: (() -> Void)? let runScript: (() -> Void)? let stopRunScript: (() -> Void)? + let testScript: (() -> Void)? + let debugScript: (() -> Void)? + let deployScript: (() -> Void)? } fileprivate struct WorktreeToolbarState { From 7da5c287757197ed2555b1e1777049854ba7ffc6 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 00:00:07 +0200 Subject: [PATCH 03/20] Add scripts settings table and expandable repo sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split repository settings into General and Scripts sub-sections via expandable DisclosureGroup in the sidebar. The Scripts section shows a list-based editor where each row has a ScriptKind picker, name field, and monospaced command field with add/remove/reorder support. Remove the old single "Run Script" ScriptSection from the General settings — lifecycle scripts (setup, archive, delete) stay as-is. Add reducer tests for addScript, removeScripts, and moveScripts actions covering edge cases (remove middle, move to end, move to beginning). --- .../Reducer/RepositorySettingsFeature.swift | 36 +++++ .../Reducer/SettingsFeature.swift | 2 +- .../Views/RepositoryScriptsSettingsView.swift | 126 ++++++++++++++++++ .../Views/RepositorySettingsView.swift | 6 - .../Views/SettingsSection.swift | 11 ++ .../Settings/Views/SettingsView.swift | 38 +++++- .../RepositorySettingsScriptTests.swift | 80 +++++++++++ 7 files changed, 289 insertions(+), 10 deletions(-) create mode 100644 SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift create mode 100644 supacodeTests/RepositorySettingsScriptTests.swift diff --git a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift index 8307259a7..dd0cade31 100644 --- a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift @@ -63,6 +63,9 @@ public struct RepositorySettingsFeature { globalPullRequestMergeStrategy: PullRequestMergeStrategy ) case branchDataLoaded([String], defaultBaseRef: String) + case addScript + case removeScripts(IndexSet) + case moveScripts(IndexSet, Int) case delegate(Delegate) case binding(BindingAction) } @@ -160,6 +163,26 @@ public struct RepositorySettingsFeature { state.isBranchDataLoaded = true return .none + case .addScript: + state.settings.scripts.append(ScriptDefinition(kind: .custom)) + return persistAndNotify(state: &state) + + case .removeScripts(let offsets): + var scripts = state.settings.scripts + for index in offsets.sorted().reversed() { + scripts.remove(at: index) + } + state.settings.scripts = scripts + return persistAndNotify(state: &state) + + case .moveScripts(let source, let destination): + var scripts = state.settings.scripts + let items = source.sorted().reversed().map { scripts.remove(at: $0) }.reversed() + let insertAt = min(destination, scripts.count) + scripts.insert(contentsOf: items, at: insertAt) + state.settings.scripts = scripts + return persistAndNotify(state: &state) + case .binding: if state.isBareRepository { state.settings.copyIgnoredOnWorktreeCreate = nil @@ -180,4 +203,17 @@ public struct RepositorySettingsFeature { } } } + + /// 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 bea1a0092..02ee92068 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -545,7 +545,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/RepositoryScriptsSettingsView.swift b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift new file mode 100644 index 000000000..81f198a96 --- /dev/null +++ b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift @@ -0,0 +1,126 @@ +import ComposableArchitecture +import SupacodeSettingsShared +import SwiftUI + +/// Settings sub-section for managing on-demand scripts. +public struct RepositoryScriptsSettingsView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + Form { + Section { + if store.settings.scripts.isEmpty { + Text("No scripts configured.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) + } else { + List { + ForEach($store.settings.scripts) { $script in + ScriptRow(script: $script) + } + .onDelete { offsets in + store.send(.removeScripts(offsets)) + } + .onMove { source, destination in + store.send(.moveScripts(source, destination)) + } + } + .listStyle(.bordered(alternatesRowBackgrounds: true)) + .frame(minHeight: 120) + } + } header: { + Text("Scripts") + Text("Launched on demand from the toolbar, command palette, or keyboard shortcut.") + } footer: { + Text("Drag to reorder. The first script is used as the default.") + } + + Section("Environment Variables") { + ScriptEnvironmentRow( + name: "SUPACODE_WORKTREE_PATH", + description: "Path to the active worktree." + ) + ScriptEnvironmentRow( + name: "SUPACODE_ROOT_PATH", + description: "Path to the repository root." + ) + } + } + .formStyle(.grouped) + .padding(.top, -20) + .padding(.leading, -8) + .padding(.trailing, -6) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + store.send(.addScript) + } label: { + Image(systemName: "plus") + .accessibilityLabel("Add Script") + } + .help("Add a new script.") + } + } + } +} + +// MARK: - Script row. + +private struct ScriptRow: View { + @Binding var script: ScriptDefinition + + var body: some View { + HStack(spacing: 12) { + Picker(selection: $script.kind) { + ForEach(ScriptKind.allCases, id: \.self) { kind in + Label(kind.defaultName, systemImage: kind.defaultSystemImage) + .tag(kind) + } + } label: { + EmptyView() + } + .labelsHidden() + .pickerStyle(.menu) + .frame(width: 100) + .onChange(of: script.kind) { _, newKind in + script.systemImage = newKind.defaultSystemImage + script.tintColor = newKind.defaultTintColor + } + TextField("Name", text: $script.name) + .frame(minWidth: 80) + TextField("Command", text: $script.command) + .monospaced() + .frame(minWidth: 120) + } + .padding(.vertical, 4) + } +} + +// MARK: - Environment row. + +private struct ScriptEnvironmentRow: View { + let name: String + let description: String + + var body: some View { + LabeledContent { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(name, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + .accessibilityLabel("Copy variable key") + } + .buttonStyle(.borderless) + .help("Copy variable key.") + } label: { + Text(name).monospaced() + Text(description) + } + } +} diff --git a/SupacodeSettingsFeature/Views/RepositorySettingsView.swift b/SupacodeSettingsFeature/Views/RepositorySettingsView.swift index d11a1ee75..72a3d0b7c 100644 --- a/SupacodeSettingsFeature/Views/RepositorySettingsView.swift +++ b/SupacodeSettingsFeature/Views/RepositorySettingsView.swift @@ -114,12 +114,6 @@ public struct RepositorySettingsView: View { 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.", 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/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 45fe16674..915618aef 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -41,6 +41,7 @@ private struct RepositoryLabel: View { struct SettingsView: View { @Bindable var store: StoreOf @Bindable var settingsStore: StoreOf + @State private var expandedRepositories: Set = [] init(store: StoreOf) { self.store = store @@ -51,7 +52,7 @@ 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 }) @@ -76,8 +77,25 @@ struct SettingsView: View { Section("Repositories") { ForEach(settingsStore.repositorySummaries, id: \.id) { repository in - RepositoryLabel(name: repository.name, rootURL: repository.rootURL) - .tag(SettingsSection.repository(repository.id)) + DisclosureGroup( + isExpanded: Binding( + get: { expandedRepositories.contains(repository.id) || selection.repositoryID == repository.id }, + set: { expanded in + if expanded { + expandedRepositories.insert(repository.id) + } else { + expandedRepositories.remove(repository.id) + } + } + ) + ) { + Label("General", systemImage: "gearshape") + .tag(SettingsSection.repository(repository.id)) + Label("Scripts", systemImage: "terminal") + .tag(SettingsSection.repositoryScripts(repository.id)) + } label: { + RepositoryLabel(name: repository.name, rootURL: repository.rootURL) + } } } } @@ -115,6 +133,20 @@ struct SettingsView: View { .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") + } } } .toolbar { diff --git a/supacodeTests/RepositorySettingsScriptTests.swift b/supacodeTests/RepositorySettingsScriptTests.swift new file mode 100644 index 000000000..89fea161a --- /dev/null +++ b/supacodeTests/RepositorySettingsScriptTests.swift @@ -0,0 +1,80 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import SupacodeSettingsFeature +@testable import SupacodeSettingsShared + +@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) { + #expect($0.settings.scripts.count == 1) + #expect($0.settings.scripts.first?.kind == .custom) + #expect($0.settings.scripts.first?.name == "Custom") + } + } + + @Test(.dependencies) func removeScriptsRemovesAtOffsets() async { + let script1 = ScriptDefinition(kind: .run, command: "npm run dev") + let script2 = ScriptDefinition(kind: .test, command: "npm test") + let script3 = ScriptDefinition(kind: .debug, command: "lldb") + let store = makeStore(scripts: [script1, script2, script3]) + store.exhaustivity = .off(showSkippedAssertions: false) + + await store.send(.removeScripts(IndexSet(integer: 1))) { + #expect($0.settings.scripts.count == 2) + #expect($0.settings.scripts[0].id == script1.id) + #expect($0.settings.scripts[1].id == script3.id) + } + } + + @Test(.dependencies) func moveScriptsReordersCorrectly() 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) + + // Move the last item to the beginning. + await store.send(.moveScripts(IndexSet(integer: 2), 0)) { + #expect($0.settings.scripts[0].id == script3.id) + #expect($0.settings.scripts[1].id == script1.id) + #expect($0.settings.scripts[2].id == script2.id) + } + } + + @Test(.dependencies) func moveScriptsToEnd() async { + let script1 = ScriptDefinition(kind: .run, command: "npm run dev") + let script2 = ScriptDefinition(kind: .test, command: "npm test") + let store = makeStore(scripts: [script1, script2]) + store.exhaustivity = .off(showSkippedAssertions: false) + + // Move the first item past the end. + await store.send(.moveScripts(IndexSet(integer: 0), 2)) { + #expect($0.settings.scripts[0].id == script2.id) + #expect($0.settings.scripts[1].id == script1.id) + } + } +} From 13a8e22c873285232ac43c884a67f1aa9091dcd1 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 00:35:27 +0200 Subject: [PATCH 04/20] Add split-button toolbar and command palette for scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace RunScriptToolbarButton with ScriptSplitButton: primary button runs the selected script, chevron dropdown lists all scripts with per-type icons and run/stop per-script. Cmd+. only stops .run kind. Rewrite AppFeature script state: replace selectedRunScript/runScriptDraft with scripts array and selectedScriptID. Wire testScript/debugScript/ deployScript actions to run the first matching script kind. Add command palette entries: "Run: " and "Stop: " generated dynamically from repository scripts. Selected script shows Cmd+R badge. Replace runScriptWorktreeIDs with runningScriptsByWorktreeID dictionary for per-definition-ID tracking. Remove RunScriptPromptView — scripts are now configured via Settings. --- supacode/App/ContentView.swift | 77 +--- .../Clients/Terminal/TerminalClient.swift | 1 + .../Features/App/Reducer/AppFeature.swift | 160 ++++++--- .../CommandPalette/CommandPaletteItem.swift | 12 +- .../Reducer/CommandPaletteFeature.swift | 56 ++- .../Views/CommandPaletteOverlayView.swift | 15 + .../Reducer/RepositoriesFeature.swift | 27 +- .../Views/WorktreeDetailView.swift | 256 ++++++++----- .../Repositories/Views/WorktreeRowsView.swift | 2 +- .../WorktreeTerminalManager.swift | 28 +- .../AppFeatureArchivedSelectionTests.swift | 8 +- supacodeTests/AppFeatureDeeplinkTests.swift | 2 +- .../AppFeatureDefaultEditorTests.swift | 5 +- supacodeTests/AppFeatureRunScriptTests.swift | 337 +++++++++++++++--- supacodeTests/RepositoriesFeatureTests.swift | 70 +++- 15 files changed, 737 insertions(+), 319 deletions(-) diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 3d315eacd..183d533c3 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -24,14 +24,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) @@ -75,17 +67,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 +74,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 ) ) } @@ -152,57 +135,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/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index d9301e915..bec10b5f2 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -21,9 +21,8 @@ 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 selectedScriptID: UUID? var notificationIndicatorCount: Int = 0 var lastKnownSystemNotificationsEnabled: Bool var pendingDeeplinks: [Deeplink] = [] @@ -39,6 +38,26 @@ struct AppFeature { self.settings = settings lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled } + + /// The script that the primary toolbar button should run. + var primaryScript: ScriptDefinition? { + if let selectedScriptID, let match = scripts.first(where: { $0.id == selectedScriptID }) { + return match + } + return scripts.first { $0.kind == .run } + } + + /// 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 { + let running = runningScriptIDs + return scripts.contains { $0.kind == .run && running.contains($0.id) } + } } enum Action { @@ -56,10 +75,12 @@ 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 testScript + case debugScript + case deployScript case closeTab case closeSurface case startSearch @@ -140,9 +161,8 @@ struct AppFeature { let repositoryPersistence = repositoryPersistence guard let worktree else { state.openActionSelection = .finder - state.selectedRunScript = "" - state.runScriptDraft = "" - state.isRunScriptPromptPresented = false + state.scripts = [] + state.selectedScriptID = nil var effects: [Effect] = [ .run { _ in await terminalClient.send(.setSelectedWorktreeID(nil)) @@ -163,8 +183,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( @@ -200,7 +218,8 @@ struct AppFeature { repositories.flatMap { $0.worktrees.map(\.id) } .filter { !archivedIDs.contains($0) || deleteScriptIDs.contains($0) } ) - state.repositories.runScriptWorktreeIDs.formIntersection(ids) + state.repositories.runningScriptsByWorktreeID = state.repositories.runningScriptsByWorktreeID + .filter { ids.contains($0.key) } let recencyIDs = CommandPaletteFeature.recencyRetentionIDs(from: repositories) let worktrees = state.repositories.worktreesForInfoWatcher() var effects: [Effect] = [ @@ -412,60 +431,42 @@ 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 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(.repository(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 - let definition = ScriptDefinition(kind: .run, command: script) + 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: .script(definition), 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 } @@ -473,6 +474,15 @@ struct AppFeature { await terminalClient.send(.stopRunScript(worktree)) } + case .testScript: + return runFirstScript(ofKind: .test, state: &state) + + case .debugScript: + return runFirstScript(ofKind: .debug, state: &state) + + case .deployScript: + return runFirstScript(ofKind: .deploy, state: &state) + case .closeTab: guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { return .none @@ -552,7 +562,8 @@ struct AppFeature { settings.openActionID, defaultEditorID: normalizedDefaultEditorID ) - state.selectedRunScript = settings.runScript + state.scripts = settings.scripts + state.selectedScriptID = settings.selectedScriptID return .none case .deeplinkReceived(let url, let source, let responseFD): @@ -749,6 +760,17 @@ 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, _))): + guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { + return .none + } + return .run { _ in + await terminalClient.send(.stopScript(worktree, definitionID: scriptID)) + } + #if DEBUG case .commandPalette(.delegate(.debugTestToast(let toast))): return .send(.repositories(.showToast(toast))) @@ -798,8 +820,18 @@ struct AppFeature { case .terminalEvent(.blockingScriptCompleted(let worktreeID, let kind, let exitCode, let tabId)): switch kind { - case .script: - 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: @@ -828,6 +860,16 @@ struct AppFeature { } } + // MARK: - Script helpers. + + /// Finds the first script of the given kind and dispatches `.runNamedScript`. + private func runFirstScript(ofKind kind: ScriptKind, state: inout State) -> Effect { + guard let definition = state.scripts.first(where: { $0.kind == kind }) else { + return .none + } + return .send(.runNamedScript(definition)) + } + // MARK: - Deeplink handling. // MARK: Deeplink dispatch. @@ -961,7 +1003,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..5e00aa58a 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), @@ -491,6 +497,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 +570,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 +610,9 @@ private func pullRequestDelegateAction( .archiveWorktree, .viewArchivedWorktrees, .refreshWorktrees, - .ghosttyCommand: + .ghosttyCommand, + .runScript, + .stopScript: return nil #if DEBUG case .debugTestToast: @@ -601,6 +621,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.name)", + subtitle: nil, + kind: .stopScript(script.id, name: script.name), + priorityTier: 0 + ) + ) + } else { + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.runScript(script.id), + title: "Run: \(script.name)", + 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..2b212baca 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.systemImage + 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 39a35da90..7ed1fab3e 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) @@ -1391,15 +1392,20 @@ 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: .script(ScriptDefinition(kind: .run)), + kind: kind, exitCode: exitCode, worktreeID: worktreeID, tabId: tabId, @@ -2911,7 +2917,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) @@ -2925,7 +2933,7 @@ struct RepositoriesFeature { state.deleteScriptWorktreeIDs = filteredDeleteScriptIDs state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs - state.runScriptWorktreeIDs = filteredRunScriptIDs + state.runningScriptsByWorktreeID = filteredRunningScripts state.archivingWorktreeIDs = filteredArchivingIDs state.worktreeInfoByID = filteredWorktreeInfo } @@ -2936,6 +2944,7 @@ struct RepositoriesFeature { state.deleteScriptWorktreeIDs = filteredDeleteScriptIDs state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs + state.runningScriptsByWorktreeID = filteredRunningScripts state.archivingWorktreeIDs = filteredArchivingIDs state.worktreeInfoByID = filteredWorktreeInfo } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index ec0816c7d..70d0eabe6 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,9 @@ struct WorktreeDetailView: View { unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, openActionSelection: openActionSelection, showExtras: commandKeyObserver.isPressed, - runScriptEnabled: runScriptEnabled, - runScriptIsRunning: runScriptIsRunning + scripts: scripts, + runningScriptIDs: runningScriptIDs, + selectedScriptID: state.selectedScriptID ) WorktreeToolbarContent( toolbarState: toolbarState, @@ -91,14 +93,23 @@ 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(.repository(repositoryID)))) + } ) } } + let hasScripts = !scripts.isEmpty + let hasRunningRunScript = state.hasRunningRunScript let actions = makeFocusedActions( hasActiveWorktree: hasActiveWorktree, - runScriptEnabled: runScriptEnabled, - runScriptIsRunning: runScriptIsRunning + hasScripts: hasScripts, + hasRunningRunScript: hasRunningRunScript, + scripts: scripts ) return applyFocusedActions(content: content, actions: actions) } @@ -224,12 +235,16 @@ struct WorktreeDetailView: View { private func makeFocusedActions( hasActiveWorktree: Bool, - runScriptEnabled: Bool, - runScriptIsRunning: Bool + hasScripts: Bool, + hasRunningRunScript: Bool, + scripts: [ScriptDefinition] ) -> FocusedActions { func action(_ appAction: AppFeature.Action) -> (() -> Void)? { hasActiveWorktree ? { store.send(appAction) } : nil } + let hasTest = scripts.contains { $0.kind == .test } + let hasDebug = scripts.contains { $0.kind == .debug } + let hasDeploy = scripts.contains { $0.kind == .deploy } return FocusedActions( openSelectedWorktree: action(.openSelectedWorktree), newTerminal: action(.newTerminal), @@ -240,11 +255,11 @@ struct WorktreeDetailView: View { navigateSearchNext: action(.navigateSearchNext), navigateSearchPrevious: action(.navigateSearchPrevious), endSearch: action(.endSearch), - runScript: runScriptEnabled ? { store.send(.runScript) } : nil, - stopRunScript: runScriptIsRunning ? { store.send(.stopRunScript) } : nil, - testScript: nil, - debugScript: nil, - deployScript: nil, + runScript: hasActiveWorktree ? { store.send(.runScript) } : nil, + stopRunScript: hasRunningRunScript ? { store.send(.stopRunScripts) } : nil, + testScript: hasTest ? { store.send(.testScript) } : nil, + debugScript: hasDebug ? { store.send(.debugScript) } : nil, + deployScript: hasDeploy ? { store.send(.deployScript) } : nil, ) } @@ -291,8 +306,22 @@ struct WorktreeDetailView: View { let unseenNotificationWorktreeCount: Int let openActionSelection: OpenWorktreeAction let showExtras: Bool - let runScriptEnabled: Bool - let runScriptIsRunning: Bool + let scripts: [ScriptDefinition] + let runningScriptIDs: Set + let selectedScriptID: UUID? + + /// The script the primary button should run. + var primaryScript: ScriptDefinition? { + if let selectedScriptID, let match = scripts.first(where: { $0.id == selectedScriptID }) { + return match + } + return scripts.first { $0.kind == .run } + } + + /// Whether any `.run`-kind script is currently running. + var hasRunningRunScript: Bool { + scripts.contains { $0.kind == .run && runningScriptIDs.contains($0.id) } + } var runScriptHelpText: String { @Shared(.settingsFile) var settingsFile @@ -316,7 +345,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 { @@ -358,19 +390,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 { + ScriptSplitButton( + toolbarState: toolbarState, + onRunScript: onRunScript, + onRunNamedScript: onRunNamedScript, + onStopScript: onStopScript, + onStopRunScripts: onStopRunScripts, + onManageScripts: onManageScripts + ) } } @@ -653,70 +681,126 @@ 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 +/// Split-button for running scripts in the toolbar. +/// Primary button runs the selected/default script. +/// Chevron dropdown lists all configured scripts with run/stop options. +private struct ScriptSplitButton: 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 + private var primaryScript: ScriptDefinition? { + toolbarState.primaryScript + } + var body: some View { - if isRunning { - button( - config: RunScriptButtonConfig( - title: "Stop", - systemImage: "stop.fill", - helpText: stopHelpText, - shortcut: stopShortcut, - isEnabled: true, - action: stopAction - )) + HStack(spacing: 0) { + primaryButton + scriptMenu + } + } + + @ViewBuilder + private var primaryButton: some View { + let hasRunning = toolbarState.hasRunningRunScript + if hasRunning { + Button { + onStopRunScripts() + } label: { + HStack(spacing: 6) { + Image(systemName: "stop.fill") + .accessibilityHidden(true) + Text("Stop") + if commandKeyObserver.isPressed { + Text(shortcutDisplay(for: AppShortcuts.stopRunScript, fallback: "")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .font(.caption) + .help(toolbarState.stopRunScriptHelpText) + } else if let primaryScript { + Button { + onRunScript() + } label: { + HStack(spacing: 6) { + Image(systemName: primaryScript.systemImage) + .accessibilityHidden(true) + Text(primaryScript.name) + if commandKeyObserver.isPressed { + Text(shortcutDisplay(for: AppShortcuts.runScript, fallback: "")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .font(.caption) + .help(toolbarState.runScriptHelpText) } else { - button( - config: RunScriptButtonConfig( - title: "Run", - systemImage: "play.fill", - helpText: runHelpText, - shortcut: runShortcut, - isEnabled: isEnabled, - action: runAction - )) + Button { + onManageScripts() + } label: { + HStack(spacing: 6) { + Image(systemName: "play.fill") + .accessibilityHidden(true) + Text("Run") + if commandKeyObserver.isPressed { + Text(shortcutDisplay(for: AppShortcuts.runScript, fallback: "")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .font(.caption) + .help("Configure scripts in Settings") } } @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) + private var scriptMenu: some View { + if !toolbarState.scripts.isEmpty { + Menu { + ForEach(toolbarState.scripts) { script in + let isRunning = toolbarState.runningScriptIDs.contains(script.id) + Button { + if isRunning { + onStopScript(script) + } else { + onRunNamedScript(script) + } + } label: { + Label( + isRunning ? "Stop: \(script.name)" : "Run: \(script.name)", + systemImage: isRunning ? "stop.fill" : script.systemImage + ) + } + .help(isRunning ? "Stop \(script.name)" : "Run \(script.name)") + } + Divider() + Button("Manage Scripts…") { + onManageScripts() } + .help("Open repository settings to manage scripts") + } label: { + Image(systemName: "chevron.down") + .font(.caption2) + .accessibilityLabel("Script menu") } + .imageScale(.small) + .menuIndicator(.hidden) + .fixedSize() + .help("Script actions") } - .font(.caption) - .help(config.helpText) - .disabled(!config.isEnabled) } - private struct RunScriptButtonConfig { - let title: String - let systemImage: String - let helpText: String - let shortcut: String - let isEnabled: Bool - let action: () -> Void + private func shortcutDisplay(for shortcut: AppShortcut, fallback: String = "none") -> String { + @Shared(.settingsFile) var settingsFile + return shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback } } @@ -734,8 +818,9 @@ private struct WorktreeToolbarPreview: View { unseenNotificationWorktreeCount: 0, openActionSelection: .finder, showExtras: false, - runScriptEnabled: true, - runScriptIsRunning: false + scripts: [ScriptDefinition(kind: .run, command: "npm run dev")], + runningScriptIDs: [], + selectedScriptID: nil ) let observer = CommandKeyObserver() observer.isPressed = false @@ -757,7 +842,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/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 73b67eea5..afe00d01e 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -161,7 +161,7 @@ private struct WorktreeRowContainer: View { hideSubtitle: hideSubtitle, hideSubtitleOnMatch: hideSubtitleOnMatch, showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), - isRunScriptRunning: store.state.runScriptWorktreeIDs.contains(row.id), + isRunScriptRunning: store.state.runningScriptsByWorktreeID[row.id] != nil, 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/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 442301f23..93cfe4592 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -123,6 +123,8 @@ final class WorktreeTerminalManager { state.ensureInitialTab(focusing: focusing) case .stopRunScript(let worktree): _ = 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/supacodeTests/AppFeatureArchivedSelectionTests.swift b/supacodeTests/AppFeatureArchivedSelectionTests.swift index 338d625fd..672fbb1ee 100644 --- a/supacodeTests/AppFeatureArchivedSelectionTests.swift +++ b/supacodeTests/AppFeatureArchivedSelectionTests.swift @@ -79,7 +79,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 +96,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..219fb32d6 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() } @@ -90,7 +90,8 @@ struct AppFeatureDefaultEditorTests { await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) await store.receive(\.worktreeSettingsLoaded) { $0.openActionSelection = .terminal - $0.selectedRunScript = "pnpm dev" + $0.scripts = localRepositorySettings.scripts + $0.selectedScriptID = localRepositorySettings.selectedScriptID } await store.finish() } diff --git a/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index a58344917..e0c94f9c8 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -10,60 +10,50 @@ 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 == .repository(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(.runScriptDraftChanged("npm run dev")) { - $0.runScriptDraft = "npm run dev" - } - await store.send(.saveRunScriptAndRun) { - $0.selectedRunScript = "npm run dev" - $0.runScriptDraft = "" - $0.isRunScriptPromptPresented = false - } - await store.receive(\.runScript) { - $0.repositories.runScriptWorktreeIDs = [worktree.id] + await store.send(.runScript) + await store.receive(\.runNamedScript) { + $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] } await store.finish() @@ -74,25 +64,122 @@ struct AppFeatureRunScriptTests { } #expect(sentWorktree == worktree) #expect(script == "npm run dev") - guard case .script(let definition) = kind else { + guard case .script(let sentDefinition) = kind else { Issue.record("Expected .script kind") return } - #expect(definition.kind == .run) - #expect(definition.command == "npm run dev") + #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(.runNamedScript(definition)) { + $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + } + await store.finish() + } + + @Test(.dependencies) func testScriptRunsFirstTestKindScript() async { + let worktree = makeWorktree() + let repositories = makeRepositoriesState(worktree: worktree) + let testScript = ScriptDefinition(kind: .test, name: "Test", command: "npm test") + let runScript = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") + let sent = LockIsolated<[TerminalClient.Command]>([]) + var initialState = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + initialState.scripts = [runScript, testScript] + let store = TestStore(initialState: initialState) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } - let savedRunScript = withDependencies { - $0.settingsFileStorage = storage.storage - } operation: { - @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings - return repositorySettings.runScript + await store.send(.testScript) + await store.receive(\.runNamedScript) { + $0.repositories.runningScriptsByWorktreeID = [worktree.id: [testScript.id]] + } + await store.finish() + + #expect(sent.value.count == 1) + guard case .runBlockingScript(_, let kind, let script) = sent.value.first else { + Issue.record("Expected runBlockingScript command") + return + } + #expect(script == "npm test") + guard case .script(let def) = kind else { + Issue.record("Expected .script kind") + return } - #expect(savedRunScript == "npm run dev") + #expect(def.kind == .test) } - @Test(.dependencies) func runScriptDoesNotOverwriteDraftWhenPromptAlreadyPresented() async { + @Test(.dependencies) func testScriptWithNoTestScriptDoesNothing() async { let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) + var initialState = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + initialState.scripts = [ScriptDefinition(kind: .run, command: "npm start")] + let store = TestStore(initialState: initialState) { + AppFeature() + } + + await store.send(.testScript) + } + + @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 = [:] + } + } + + @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, @@ -100,18 +187,160 @@ struct AppFeatureRunScriptTests { ) ) { AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } } - await store.send(.runScript) { - $0.runScriptDraft = "" - $0.isRunScriptPromptPresented = true + await store.send(.stopRunScripts) + await store.finish() + + #expect(sent.value.count == 1) + guard case .stopRunScript(let sentWorktree) = sent.value.first else { + Issue.record("Expected stopRunScript command") + return } - await store.send(.runScriptDraftChanged("pnpm dev")) { - $0.runScriptDraft = "pnpm dev" + #expect(sentWorktree == worktree) + } + + @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(.runScript) - #expect(store.state.runScriptDraft == "pnpm dev") - #expect(store.state.isRunScriptPromptPresented) + + 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(sentWorktree == worktree) + #expect(definitionID == definition.id) + } + + @Test(.dependencies) func debugScriptRunsFirstDebugKindScript() async { + let worktree = makeWorktree() + let repositories = makeRepositoriesState(worktree: worktree) + let debugDef = ScriptDefinition(kind: .debug, name: "Debug", command: "lldb app") + let sent = LockIsolated<[TerminalClient.Command]>([]) + var initialState = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State(), + ) + initialState.scripts = [debugDef] + let store = TestStore(initialState: initialState) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.debugScript) + await store.receive(\.runNamedScript) { + $0.repositories.runningScriptsByWorktreeID = [worktree.id: [debugDef.id]] + } + await store.finish() + + #expect(sent.value.count == 1) + guard case .runBlockingScript(_, let kind, _) = sent.value.first else { + Issue.record("Expected runBlockingScript command") + return + } + guard case .script(let def) = kind else { + Issue.record("Expected .script kind") + return + } + #expect(def.kind == .debug) + } + + @Test(.dependencies) func deployScriptRunsFirstDeployKindScript() async { + let worktree = makeWorktree() + let repositories = makeRepositoriesState(worktree: worktree) + let deployDef = ScriptDefinition(kind: .deploy, name: "Deploy", command: "./deploy.sh") + let sent = LockIsolated<[TerminalClient.Command]>([]) + var initialState = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State(), + ) + initialState.scripts = [deployDef] + let store = TestStore(initialState: initialState) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.deployScript) + await store.receive(\.runNamedScript) { + $0.repositories.runningScriptsByWorktreeID = [worktree.id: [deployDef.id]] + } + await store.finish() + + #expect(sent.value.count == 1) + guard case .runBlockingScript(_, let kind, _) = sent.value.first else { + Issue.record("Expected runBlockingScript command") + return + } + guard case .script(let def) = kind else { + Issue.record("Expected .script kind") + return + } + #expect(def.kind == .deploy) + } + + @Test(.dependencies) func debugScriptWithNoDebugScriptDoesNothing() async { + let worktree = makeWorktree() + let repositories = makeRepositoriesState(worktree: worktree) + var initialState = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State(), + ) + initialState.scripts = [ScriptDefinition(kind: .run, command: "npm start")] + let store = TestStore(initialState: initialState) { + AppFeature() + } + + await store.send(.debugScript) + } + + @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] + settings.selectedScriptID = definition.id + let store = TestStore( + initialState: AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.worktreeSettingsLoaded(settings, worktreeID: worktree.id)) + #expect(store.state.scripts == [definition]) + #expect(store.state.selectedScriptID == definition.id) } private func makeWorktree() -> Worktree { diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index d85a0d29a..a473018ac 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1784,20 +1784,29 @@ 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: .script(ScriptDefinition(kind: .run)), + kind: .script(definition), exitMessage: "Script failed (exit code 1).", worktreeID: worktree.id, repoName: "repo", @@ -1806,34 +1815,52 @@ 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 +1871,27 @@ 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: .script(ScriptDefinition(kind: .run)), + kind: .script(definition), exitMessage: "Script failed (exit code 1).", worktreeID: worktree.id, tabId: tabId, From 4ee3c66addeb70e6ddbc84b6df105a01e5856f01 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 00:52:32 +0200 Subject: [PATCH 05/20] Add color-cycling sidebar indicator for running scripts Replace the single-color boolean PingDot with MultiColorPingDot that cycles through tint colors of all running script types per worktree. Uses TimelineView for smooth color transitions and falls back to a static dot when accessibilityReduceMotion is enabled. Add scriptTintColorByID cache to RepositoriesFeature.State for efficient color lookup without passing full ScriptDefinition arrays through the view hierarchy. Cache is maintained alongside runningScriptsByWorktreeID on script start, completion, and pruning. --- .../Features/App/Reducer/AppFeature.swift | 4 + .../Reducer/RepositoriesFeature.swift | 20 +++ .../Repositories/Views/WorktreeRow.swift | 121 ++++++++++++++++-- .../Repositories/Views/WorktreeRowsView.swift | 2 +- .../AppFeatureArchivedSelectionTests.swift | 2 + supacodeTests/AppFeatureRunScriptTests.swift | 7 + supacodeTests/RepositoriesFeatureTests.swift | 8 ++ 7 files changed, 152 insertions(+), 12 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index bec10b5f2..e98ca2fdb 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -220,6 +220,9 @@ struct AppFeature { ) state.repositories.runningScriptsByWorktreeID = state.repositories.runningScriptsByWorktreeID .filter { ids.contains($0.key) } + let remainingScriptIDs = Set(state.repositories.runningScriptsByWorktreeID.values.flatMap { $0 }) + state.repositories.scriptTintColorByID = state.repositories.scriptTintColorByID + .filter { remainingScriptIDs.contains($0.key) } let recencyIDs = CommandPaletteFeature.recencyRetentionIDs(from: repositories) let worktrees = state.repositories.worktreesForInfoWatcher() var effects: [Effect] = [ @@ -452,6 +455,7 @@ struct AppFeature { var ids = state.repositories.runningScriptsByWorktreeID[worktree.id] ?? [] ids.insert(definition.id) state.repositories.runningScriptsByWorktreeID[worktree.id] = ids + state.repositories.scriptTintColorByID[definition.id] = definition.tintColor return .run { _ in await terminalClient.send( .runBlockingScript(worktree, kind: .script(definition), script: definition.command) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 7ed1fab3e..8c212abb2 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -78,6 +78,7 @@ struct RepositoriesFeature { var pendingSetupScriptWorktreeIDs: Set = [] var pendingTerminalFocusWorktreeIDs: Set = [] var runningScriptsByWorktreeID: [Worktree.ID: Set] = [:] + var scriptTintColorByID: [UUID: TerminalTabTintColor] = [:] var archivingWorktreeIDs: Set = [] var deleteScriptWorktreeIDs: Set = [] var deletingWorktreeIDs: Set = [] @@ -1403,6 +1404,12 @@ struct RepositoriesFeature { } else { state.runningScriptsByWorktreeID[worktreeID] = ids } + // Remove the cached tint color when the script ID is no longer + // referenced by any worktree. + let stillReferenced = state.runningScriptsByWorktreeID.values.contains { $0.contains(scriptID) } + if !stillReferenced { + state.scriptTintColorByID.removeValue(forKey: scriptID) + } guard let exitCode, exitCode != 0 else { return .none } state.alert = blockingScriptFailureAlert( kind: kind, @@ -2920,6 +2927,10 @@ struct RepositoriesFeature { let filteredRunningScripts = state.runningScriptsByWorktreeID.filter { availableWorktreeIDs.contains($0.key) } + let remainingScriptIDs = Set(filteredRunningScripts.values.flatMap { $0 }) + let filteredScriptTintColors = state.scriptTintColorByID.filter { + remainingScriptIDs.contains($0.key) + } let filteredArchivingIDs = state.archivingWorktreeIDs let filteredWorktreeInfo = state.worktreeInfoByID.filter { availableWorktreeIDs.contains($0.key) @@ -2934,6 +2945,7 @@ struct RepositoriesFeature { state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs state.runningScriptsByWorktreeID = filteredRunningScripts + state.scriptTintColorByID = filteredScriptTintColors state.archivingWorktreeIDs = filteredArchivingIDs state.worktreeInfoByID = filteredWorktreeInfo } @@ -2945,6 +2957,7 @@ struct RepositoriesFeature { state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs state.runningScriptsByWorktreeID = filteredRunningScripts + state.scriptTintColorByID = filteredScriptTintColors state.archivingWorktreeIDs = filteredArchivingIDs state.worktreeInfoByID = filteredWorktreeInfo } @@ -3154,6 +3167,13 @@ extension RepositoriesFeature.State { return nil } + /// Tint colors for scripts currently running in the given worktree, + /// ordered deterministically by script ID. + func runningScriptColors(for worktreeID: Worktree.ID) -> [TerminalTabTintColor] { + guard let scriptIDs = runningScriptsByWorktreeID[worktreeID] else { return [] } + return scriptIDs.sorted().compactMap { scriptTintColorByID[$0] } + } + 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/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index 0cd996975..702ae4fe0 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 ) @@ -415,6 +417,103 @@ extension LabelStyle where Self == VerticallyCenteredLabelStyle { static var verticallyCentered: VerticallyCenteredLabelStyle { .init() } } +// MARK: - Multi-color ping dot. + +/// 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") + } +} + +/// 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. private struct PingDot: View { diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index afe00d01e..96b78506b 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -161,7 +161,7 @@ private struct WorktreeRowContainer: View { hideSubtitle: hideSubtitle, hideSubtitleOnMatch: hideSubtitleOnMatch, showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), - isRunScriptRunning: store.state.runningScriptsByWorktreeID[row.id] != nil, + runningScriptColors: store.state.runningScriptColors(for: row.id), isTaskRunning: terminalManager.stateIfExists(for: row.id)?.taskStatus == .running, showsNotificationIndicator: terminalManager.hasUnseenNotifications(for: row.id), notifications: terminalManager.stateIfExists(for: row.id)?.notifications ?? [], diff --git a/supacodeTests/AppFeatureArchivedSelectionTests.swift b/supacodeTests/AppFeatureArchivedSelectionTests.swift index 672fbb1ee..4e9d5d9bf 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 @@ -84,6 +85,7 @@ struct AppFeatureArchivedSelectionTests { activeWorktree.id: [scriptID], archivedWorktree.id: [scriptID], ] + appState.repositories.scriptTintColorByID = [scriptID: .green] let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: appState) { AppFeature() diff --git a/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index e0c94f9c8..3b2cbfc2d 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -54,6 +54,7 @@ struct AppFeatureRunScriptTests { await store.send(.runScript) await store.receive(\.runNamedScript) { $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + $0.repositories.scriptTintColorByID = [definition.id: definition.tintColor] } await store.finish() @@ -89,6 +90,7 @@ struct AppFeatureRunScriptTests { await store.send(.runNamedScript(definition)) { $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + $0.repositories.scriptTintColorByID = [definition.id: definition.tintColor] } await store.finish() } @@ -115,6 +117,7 @@ struct AppFeatureRunScriptTests { await store.send(.testScript) await store.receive(\.runNamedScript) { $0.repositories.runningScriptsByWorktreeID = [worktree.id: [testScript.id]] + $0.repositories.scriptTintColorByID = [testScript.id: testScript.tintColor] } await store.finish() @@ -152,6 +155,7 @@ struct AppFeatureRunScriptTests { let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") var repositoriesState = repositories repositoriesState.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + repositoriesState.scriptTintColorByID = [definition.id: definition.tintColor] let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -173,6 +177,7 @@ struct AppFeatureRunScriptTests { ) ) { $0.repositories.runningScriptsByWorktreeID = [:] + $0.repositories.scriptTintColorByID = [:] } } @@ -255,6 +260,7 @@ struct AppFeatureRunScriptTests { await store.send(.debugScript) await store.receive(\.runNamedScript) { $0.repositories.runningScriptsByWorktreeID = [worktree.id: [debugDef.id]] + $0.repositories.scriptTintColorByID = [debugDef.id: debugDef.tintColor] } await store.finish() @@ -291,6 +297,7 @@ struct AppFeatureRunScriptTests { await store.send(.deployScript) await store.receive(\.runNamedScript) { $0.repositories.runningScriptsByWorktreeID = [worktree.id: [deployDef.id]] + $0.repositories.scriptTintColorByID = [deployDef.id: deployDef.tintColor] } await store.finish() diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index a473018ac..9479682ed 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1791,6 +1791,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + state.scriptTintColorByID = [definition.id: definition.tintColor] let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -1805,6 +1806,7 @@ struct RepositoriesFeatureTests { ) ) { $0.runningScriptsByWorktreeID = [:] + $0.scriptTintColorByID = [:] $0.alert = expectedScriptFailureAlert( kind: .script(definition), exitMessage: "Script failed (exit code 1).", @@ -1822,6 +1824,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + state.scriptTintColorByID = [definition.id: definition.tintColor] let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -1836,6 +1839,7 @@ struct RepositoriesFeatureTests { ) ) { $0.runningScriptsByWorktreeID = [:] + $0.scriptTintColorByID = [:] } #expect(store.state.alert == nil) } @@ -1847,6 +1851,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + state.scriptTintColorByID = [definition.id: definition.tintColor] let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -1861,6 +1866,7 @@ struct RepositoriesFeatureTests { ) ) { $0.runningScriptsByWorktreeID = [:] + $0.scriptTintColorByID = [:] } #expect(store.state.alert == nil) } @@ -1874,6 +1880,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] + state.scriptTintColorByID = [definition.id: definition.tintColor] let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -1890,6 +1897,7 @@ struct RepositoriesFeatureTests { ) ) { $0.runningScriptsByWorktreeID = [:] + $0.scriptTintColorByID = [:] $0.alert = expectedScriptFailureAlert( kind: .script(definition), exitMessage: "Script failed (exit code 1).", From b13583a50065aa8715cbe6a27f740b75fa43352e Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 01:20:09 +0200 Subject: [PATCH 06/20] Fix review findings: encode compat, decode resilience, and cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add custom encode(to:) deriving runScript from first .run script for backward compatibility with older app versions. - Guard against running the same script definition twice concurrently. - Navigate to repositoryScripts settings (not General) when no scripts are configured and user presses Cmd+R. - Use try? for scripts decode so unknown ScriptKind values from future versions fall back gracefully instead of crashing. - Remove selectedScriptID entirely — primary toolbar button always runs the first .run-kind script, matching the "Open In" pattern. - Enforce at most one script per predefined kind in settings (only .custom allows duplicates). - Add Codable migration tests: legacy decode, dual-key decode, encode round-trip, and unknown-kind resilience. --- .../Reducer/RepositorySettingsFeature.swift | 11 ++ .../Models/RepositorySettings.swift | 32 ++++-- .../Features/App/Reducer/AppFeature.swift | 14 +-- .../Views/WorktreeDetailView.swift | 10 +- .../AppFeatureDefaultEditorTests.swift | 1 - supacodeTests/AppFeatureRunScriptTests.swift | 4 +- .../RepositorySettingsScriptTests.swift | 103 ++++++++++++++++++ 7 files changed, 144 insertions(+), 31 deletions(-) diff --git a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift index dd0cade31..354a6f4f5 100644 --- a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift @@ -188,6 +188,17 @@ public struct RepositorySettingsFeature { state.settings.copyIgnoredOnWorktreeCreate = nil state.settings.copyUntrackedOnWorktreeCreate = nil } + // Enforce at most one script per predefined kind. + // Only `.custom` allows duplicates. + var seenKinds: Set = [] + for index in state.settings.scripts.indices { + let kind = state.settings.scripts[index].kind + guard kind != .custom else { continue } + guard seenKinds.insert(kind).inserted else { + state.settings.scripts[index].kind = .custom + continue + } + } let rootURL = state.rootURL var normalizedSettings = state.settings normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( diff --git a/SupacodeSettingsShared/Models/RepositorySettings.swift b/SupacodeSettingsShared/Models/RepositorySettings.swift index c54d3b9ac..e2c4c9f6d 100644 --- a/SupacodeSettingsShared/Models/RepositorySettings.swift +++ b/SupacodeSettingsShared/Models/RepositorySettings.swift @@ -6,7 +6,6 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { public var deleteScript: String public var runScript: String public var scripts: [ScriptDefinition] - public var selectedScriptID: UUID? public var openActionID: String public var worktreeBaseRef: String? public var worktreeBaseDirectoryPath: String? @@ -20,7 +19,6 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { case deleteScript case runScript case scripts - case selectedScriptID case openActionID case worktreeBaseRef case worktreeBaseDirectoryPath @@ -35,13 +33,12 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { deleteScript: "", runScript: "", scripts: [], - selectedScriptID: nil, openActionID: OpenWorktreeAction.automaticSettingsID, worktreeBaseRef: nil, worktreeBaseDirectoryPath: nil, copyIgnoredOnWorktreeCreate: nil, copyUntrackedOnWorktreeCreate: nil, - pullRequestMergeStrategy: nil + pullRequestMergeStrategy: nil, ) public init( @@ -50,7 +47,6 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { deleteScript: String, runScript: String, scripts: [ScriptDefinition] = [], - selectedScriptID: UUID? = nil, openActionID: String, worktreeBaseRef: String?, worktreeBaseDirectoryPath: String? = nil, @@ -63,7 +59,6 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { self.deleteScript = deleteScript self.runScript = runScript self.scripts = scripts - self.selectedScriptID = selectedScriptID self.openActionID = openActionID self.worktreeBaseRef = worktreeBaseRef self.worktreeBaseDirectoryPath = worktreeBaseDirectoryPath @@ -88,7 +83,9 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { ?? Self.default.runScript // Migrate legacy `runScript` into the new `scripts` array when // the `scripts` key is absent from persisted JSON. - let decodedScripts = try container.decodeIfPresent([ScriptDefinition].self, forKey: .scripts) + // Use `try?` so an unknown `ScriptKind` raw value from a future + // version doesn't crash the entire settings decode. + let decodedScripts = try? container.decodeIfPresent([ScriptDefinition].self, forKey: .scripts) if let decodedScripts { scripts = decodedScripts } else if !runScript.isEmpty { @@ -96,9 +93,6 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { } else { scripts = Self.default.scripts } - selectedScriptID = - try container.decodeIfPresent(UUID.self, forKey: .selectedScriptID) - ?? Self.default.selectedScriptID openActionID = try container.decodeIfPresent(String.self, forKey: .openActionID) ?? Self.default.openActionID @@ -116,4 +110,22 @@ 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. + let derivedRunScript = scripts.first(where: { $0.kind == .run })?.command ?? runScript + 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) + } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index e98ca2fdb..cbc4c13d4 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -22,7 +22,6 @@ struct AppFeature { var commandPalette = CommandPaletteFeature.State() var openActionSelection: OpenWorktreeAction = .finder var scripts: [ScriptDefinition] = [] - var selectedScriptID: UUID? var notificationIndicatorCount: Int = 0 var lastKnownSystemNotificationsEnabled: Bool var pendingDeeplinks: [Deeplink] = [] @@ -41,10 +40,7 @@ struct AppFeature { /// The script that the primary toolbar button should run. var primaryScript: ScriptDefinition? { - if let selectedScriptID, let match = scripts.first(where: { $0.id == selectedScriptID }) { - return match - } - return scripts.first { $0.kind == .run } + scripts.first { $0.kind == .run } } /// Running script IDs for the currently selected worktree. @@ -162,7 +158,6 @@ struct AppFeature { guard let worktree else { state.openActionSelection = .finder state.scripts = [] - state.selectedScriptID = nil var effects: [Effect] = [ .run { _ in await terminalClient.send(.setSelectedWorktreeID(nil)) @@ -436,12 +431,12 @@ struct AppFeature { case .runScript: // Find the selected or primary script and run it. guard let definition = state.primaryScript else { - // No scripts configured — open repository settings. + // No scripts configured — open repository scripts settings. guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { return .none } let repositoryID = worktree.repositoryRootURL.path(percentEncoded: false) - return .send(.settings(.setSelection(.repository(repositoryID)))) + return .send(.settings(.setSelection(.repositoryScripts(repositoryID)))) } return .send(.runNamedScript(definition)) @@ -449,6 +444,8 @@ struct AppFeature { guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { return .none } + // 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]) @@ -567,7 +564,6 @@ struct AppFeature { defaultEditorID: normalizedDefaultEditorID ) state.scripts = settings.scripts - state.selectedScriptID = settings.selectedScriptID return .none case .deeplinkReceived(let url, let source, let responseFD): diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 70d0eabe6..cbf77ed6f 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -73,7 +73,6 @@ struct WorktreeDetailView: View { showExtras: commandKeyObserver.isPressed, scripts: scripts, runningScriptIDs: runningScriptIDs, - selectedScriptID: state.selectedScriptID ) WorktreeToolbarContent( toolbarState: toolbarState, @@ -308,14 +307,10 @@ struct WorktreeDetailView: View { let showExtras: Bool let scripts: [ScriptDefinition] let runningScriptIDs: Set - let selectedScriptID: UUID? - /// The script the primary button should run. + /// The script the primary toolbar button should run (always the `.run` script). var primaryScript: ScriptDefinition? { - if let selectedScriptID, let match = scripts.first(where: { $0.id == selectedScriptID }) { - return match - } - return scripts.first { $0.kind == .run } + scripts.first { $0.kind == .run } } /// Whether any `.run`-kind script is currently running. @@ -820,7 +815,6 @@ private struct WorktreeToolbarPreview: View { showExtras: false, scripts: [ScriptDefinition(kind: .run, command: "npm run dev")], runningScriptIDs: [], - selectedScriptID: nil ) let observer = CommandKeyObserver() observer.isPressed = false diff --git a/supacodeTests/AppFeatureDefaultEditorTests.swift b/supacodeTests/AppFeatureDefaultEditorTests.swift index 219fb32d6..855be7d7d 100644 --- a/supacodeTests/AppFeatureDefaultEditorTests.swift +++ b/supacodeTests/AppFeatureDefaultEditorTests.swift @@ -91,7 +91,6 @@ struct AppFeatureDefaultEditorTests { await store.receive(\.worktreeSettingsLoaded) { $0.openActionSelection = .terminal $0.scripts = localRepositorySettings.scripts - $0.selectedScriptID = localRepositorySettings.selectedScriptID } await store.finish() } diff --git a/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index 3b2cbfc2d..50a134ddb 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -30,7 +30,7 @@ struct AppFeatureRunScriptTests { await store.send(.runScript) await store.receive(\.settings.setSelection) - #expect(store.state.settings.selection == .repository(expectedRepositoryID)) + #expect(store.state.settings.selection == .repositoryScripts(expectedRepositoryID)) } @Test(.dependencies) func runScriptRunsFirstRunKindScript() async { @@ -334,7 +334,6 @@ struct AppFeatureRunScriptTests { let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") var settings = RepositorySettings.default settings.scripts = [definition] - settings.selectedScriptID = definition.id let store = TestStore( initialState: AppFeature.State( repositories: repositories, @@ -347,7 +346,6 @@ struct AppFeatureRunScriptTests { await store.send(.worktreeSettingsLoaded(settings, worktreeID: worktree.id)) #expect(store.state.scripts == [definition]) - #expect(store.state.selectedScriptID == definition.id) } private func makeWorktree() -> Worktree { diff --git a/supacodeTests/RepositorySettingsScriptTests.swift b/supacodeTests/RepositorySettingsScriptTests.swift index 89fea161a..73bfbcc5f 100644 --- a/supacodeTests/RepositorySettingsScriptTests.swift +++ b/supacodeTests/RepositorySettingsScriptTests.swift @@ -6,6 +6,109 @@ 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 decodeWithUnknownScriptKindFallsBackGracefully() throws { + // An unknown `kind` value should not crash; scripts should fall + // back to an empty array. + let json = """ + { + "setupScript": "", + "archiveScript": "", + "deleteScript": "", + "runScript": "", + "scripts": [ + {"id": "00000000-0000-0000-0000-000000000001", "kind": "unknown_future_kind", "name": "X", "systemImage": "star", "tintColor": "red", "command": "echo hi"} + ], + "openActionID": "automatic" + } + """ + let data = Data(json.utf8) + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) + #expect(settings.scripts.isEmpty) + } +} + +/// 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") From c173752d5db032ebd22b137c9c8f0be3dc4a6a5d Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 01:47:41 +0200 Subject: [PATCH 07/20] Add lint/format script types, lock kind at creation, displayName Add .lint and .format cases to ScriptKind with dedicated SF Symbols and colors. Add displayName computed property to ScriptDefinition: predefined types use their kind name, custom types use user name. Lock script kind at creation: the + button opens a Menu showing only unused predefined kinds plus always-available custom. Kind picker replaced with static icon in script rows. Uniqueness enforced at insertion in addScript(ScriptKind). Move TerminalTabTintColor.color to shared module so SupacodeSettingsFeature can access it. Add lintScript/formatScript keyboard shortcuts (Cmd+Shift+L/F), menu items, and focused value wiring. --- .../Reducer/RepositorySettingsFeature.swift | 21 ++++------- .../Views/RepositoryScriptsSettingsView.swift | 35 +++++++++---------- SupacodeSettingsShared/App/AppShortcuts.swift | 11 +++++- .../Models/ScriptDefinition.swift | 6 ++++ .../Models/ScriptKind.swift | 8 +++++ .../Models/TerminalTabTintColor.swift | 15 ++++++++ supacode/Commands/WorktreeCommands.swift | 34 ++++++++++++++++++ .../Features/App/Reducer/AppFeature.swift | 8 +++++ .../Views/WorktreeDetailView.swift | 8 +++++ .../Models/TerminalTabTintColor.swift | 19 ++-------- .../TabBar/Views/TerminalTabLabelView.swift | 1 + .../RepositorySettingsScriptTests.swift | 20 ++++++++++- 12 files changed, 136 insertions(+), 50 deletions(-) diff --git a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift index 354a6f4f5..502a6f7a2 100644 --- a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift @@ -63,7 +63,7 @@ public struct RepositorySettingsFeature { globalPullRequestMergeStrategy: PullRequestMergeStrategy ) case branchDataLoaded([String], defaultBaseRef: String) - case addScript + case addScript(ScriptKind) case removeScripts(IndexSet) case moveScripts(IndexSet, Int) case delegate(Delegate) @@ -163,8 +163,12 @@ public struct RepositorySettingsFeature { state.isBranchDataLoaded = true return .none - case .addScript: - state.settings.scripts.append(ScriptDefinition(kind: .custom)) + 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 .removeScripts(let offsets): @@ -188,17 +192,6 @@ public struct RepositorySettingsFeature { state.settings.copyIgnoredOnWorktreeCreate = nil state.settings.copyUntrackedOnWorktreeCreate = nil } - // Enforce at most one script per predefined kind. - // Only `.custom` allows duplicates. - var seenKinds: Set = [] - for index in state.settings.scripts.indices { - let kind = state.settings.scripts[index].kind - guard kind != .custom else { continue } - guard seenKinds.insert(kind).inserted else { - state.settings.scripts[index].kind = .custom - continue - } - } let rootURL = state.rootURL var normalizedSettings = state.settings normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( diff --git a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift index 81f198a96..9e8119a8e 100644 --- a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift +++ b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift @@ -57,8 +57,14 @@ public struct RepositoryScriptsSettingsView: View { .padding(.trailing, -6) .toolbar { ToolbarItem(placement: .primaryAction) { - Button { - store.send(.addScript) + Menu { + ForEach(ScriptKind.allCases, id: \.self) { kind in + Button { + store.send(.addScript(kind)) + } label: { + Label(kind.defaultName, systemImage: kind.defaultSystemImage) + } + } } label: { Image(systemName: "plus") .accessibilityLabel("Add Script") @@ -76,23 +82,16 @@ private struct ScriptRow: View { var body: some View { HStack(spacing: 12) { - Picker(selection: $script.kind) { - ForEach(ScriptKind.allCases, id: \.self) { kind in - Label(kind.defaultName, systemImage: kind.defaultSystemImage) - .tag(kind) - } - } label: { - EmptyView() - } - .labelsHidden() - .pickerStyle(.menu) - .frame(width: 100) - .onChange(of: script.kind) { _, newKind in - script.systemImage = newKind.defaultSystemImage - script.tintColor = newKind.defaultTintColor + Image(systemName: script.systemImage) + .foregroundStyle(script.tintColor.color) + .frame(width: 20) + if script.kind == .custom { + TextField("Name", text: $script.name) + .frame(minWidth: 80) + } else { + Text(script.kind.defaultName) + .frame(minWidth: 80, alignment: .leading) } - TextField("Name", text: $script.name) - .frame(minWidth: 80) TextField("Command", text: $script.command) .monospaced() .frame(minWidth: 120) diff --git a/SupacodeSettingsShared/App/AppShortcuts.swift b/SupacodeSettingsShared/App/AppShortcuts.swift index 88818eefb..f91965029 100644 --- a/SupacodeSettingsShared/App/AppShortcuts.swift +++ b/SupacodeSettingsShared/App/AppShortcuts.swift @@ -14,6 +14,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case openFinder, openRepository, openPullRequest, copyPath case runScript, stopRunScript case testScript, debugScript, deployScript + case lintScript, formatScript // Stable string key for JSON dictionary persistence. public var codingKey: CodingKey { @@ -57,6 +58,8 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case .testScript: "testScript" case .debugScript: "debugScript" case .deployScript: "deployScript" + case .lintScript: "lintScript" + case .formatScript: "formatScript" } } @@ -83,6 +86,8 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep "testScript": .testScript, "debugScript": .debugScript, "deployScript": .deployScript, + "lintScript": .lintScript, + "formatScript": .formatScript, ] private init?(stableKey: String) { @@ -122,6 +127,8 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case .testScript: "Test Script" case .debugScript: "Debug Script" case .deployScript: "Deploy Script" + case .lintScript: "Lint Script" + case .formatScript: "Format Script" } } } @@ -329,6 +336,8 @@ public enum AppShortcuts { public static let testScript = AppShortcut(id: .testScript, key: "u", modifiers: [.command, .shift]) public static let debugScript = AppShortcut(id: .debugScript, key: "y", modifiers: [.command, .shift]) public static let deployScript = AppShortcut(id: .deployScript, key: "d", modifiers: [.command, .shift]) + public static let lintScript = AppShortcut(id: .lintScript, key: "l", modifiers: [.command, .shift]) + public static let formatScript = AppShortcut(id: .formatScript, key: "f", modifiers: [.command, .shift]) public static let worktreeSelection: [AppShortcut] = [ selectWorktree1, selectWorktree2, selectWorktree3, selectWorktree4, selectWorktree5, @@ -352,7 +361,7 @@ public enum AppShortcuts { category: .actions, shortcuts: [ openFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript, - testScript, debugScript, deployScript, + testScript, debugScript, deployScript, lintScript, formatScript, ] ), ] diff --git a/SupacodeSettingsShared/Models/ScriptDefinition.swift b/SupacodeSettingsShared/Models/ScriptDefinition.swift index 2dbdc9e5f..256534233 100644 --- a/SupacodeSettingsShared/Models/ScriptDefinition.swift +++ b/SupacodeSettingsShared/Models/ScriptDefinition.swift @@ -11,6 +11,12 @@ public nonisolated struct ScriptDefinition: Identifiable, Codable, Equatable, Ha public var tintColor: TerminalTabTintColor public var command: String + /// 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 + } + public nonisolated init( id: UUID = UUID(), kind: ScriptKind, diff --git a/SupacodeSettingsShared/Models/ScriptKind.swift b/SupacodeSettingsShared/Models/ScriptKind.swift index 88a1b53b5..d52b43b98 100644 --- a/SupacodeSettingsShared/Models/ScriptKind.swift +++ b/SupacodeSettingsShared/Models/ScriptKind.swift @@ -8,6 +8,8 @@ public enum ScriptKind: String, Codable, CaseIterable, Hashable, Sendable { case debug case test case deploy + case lint + case format case custom /// Default display name shown in UI when the user hasn't provided one. @@ -17,6 +19,8 @@ public enum ScriptKind: String, Codable, CaseIterable, Hashable, Sendable { case .debug: "Debug" case .test: "Test" case .deploy: "Deploy" + case .lint: "Lint" + case .format: "Format" case .custom: "Custom" } } @@ -28,6 +32,8 @@ public enum ScriptKind: String, Codable, CaseIterable, Hashable, Sendable { case .debug: "ant.fill" case .test: "checkmark.diamond.fill" case .deploy: "arrow.up.circle.fill" + case .lint: "exclamationmark.triangle.fill" + case .format: "text.alignleft" case .custom: "terminal.fill" } } @@ -39,6 +45,8 @@ public enum ScriptKind: String, Codable, CaseIterable, Hashable, Sendable { case .debug: .orange case .test: .blue case .deploy: .purple + case .lint: .yellow + case .format: .teal case .custom: .teal } } diff --git a/SupacodeSettingsShared/Models/TerminalTabTintColor.swift b/SupacodeSettingsShared/Models/TerminalTabTintColor.swift index 2f07daa59..93ee421ab 100644 --- a/SupacodeSettingsShared/Models/TerminalTabTintColor.swift +++ b/SupacodeSettingsShared/Models/TerminalTabTintColor.swift @@ -1,3 +1,5 @@ +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 { @@ -8,4 +10,17 @@ public enum TerminalTabTintColor: String, Codable, CaseIterable, Hashable, Senda 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 + } + } } diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index fba873f21..033e91193 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -16,6 +16,8 @@ struct WorktreeCommands: Commands { @FocusedValue(\.testScriptAction) private var testScriptAction @FocusedValue(\.debugScriptAction) private var debugScriptAction @FocusedValue(\.deployScriptAction) private var deployScriptAction + @FocusedValue(\.lintScriptAction) private var lintScriptAction + @FocusedValue(\.formatScriptAction) private var formatScriptAction @FocusedValue(\.visibleHotkeyWorktreeRows) private var visibleHotkeyWorktreeRows init(store: StoreOf) { @@ -44,6 +46,8 @@ struct WorktreeCommands: Commands { let test = AppShortcuts.testScript.effective(from: overrides) let debug = AppShortcuts.debugScript.effective(from: overrides) let deploy = AppShortcuts.deployScript.effective(from: overrides) + let lint = AppShortcuts.lintScript.effective(from: overrides) + let format = AppShortcuts.formatScript.effective(from: overrides) CommandMenu("Worktrees") { // Creation and opening. Button("New Worktree…", systemImage: "plus") { @@ -126,6 +130,18 @@ struct WorktreeCommands: Commands { .appKeyboardShortcut(deploy) .help("Deploy Script (\(deploy?.display ?? "none"))") .disabled(deployScriptAction == nil) + Button("Lint Script", systemImage: "exclamationmark.triangle") { + lintScriptAction?() + } + .appKeyboardShortcut(lint) + .help("Lint Script (\(lint?.display ?? "none"))") + .disabled(lintScriptAction == nil) + Button("Format Script", systemImage: "text.alignleft") { + formatScriptAction?() + } + .appKeyboardShortcut(format) + .help("Format Script (\(format?.display ?? "none"))") + .disabled(formatScriptAction == nil) Divider() // Navigation. Button("Select Next", systemImage: "chevron.down") { @@ -270,6 +286,16 @@ extension FocusedValues { set { self[DeployScriptActionKey.self] = newValue } } + var lintScriptAction: (() -> Void)? { + get { self[LintScriptActionKey.self] } + set { self[LintScriptActionKey.self] = newValue } + } + + var formatScriptAction: (() -> Void)? { + get { self[FormatScriptActionKey.self] } + set { self[FormatScriptActionKey.self] = newValue } + } + var visibleHotkeyWorktreeRows: [WorktreeRowModel]? { get { self[VisibleHotkeyWorktreeRowsKey.self] } set { self[VisibleHotkeyWorktreeRowsKey.self] = newValue } @@ -296,6 +322,14 @@ private struct DeployScriptActionKey: FocusedValueKey { typealias Value = () -> Void } +private struct LintScriptActionKey: FocusedValueKey { + typealias Value = () -> Void +} + +private struct FormatScriptActionKey: FocusedValueKey { + typealias Value = () -> Void +} + private struct VisibleHotkeyWorktreeRowsKey: FocusedValueKey { typealias Value = [WorktreeRowModel] } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index cbc4c13d4..7276f0dc3 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -77,6 +77,8 @@ struct AppFeature { case testScript case debugScript case deployScript + case lintScript + case formatScript case closeTab case closeSurface case startSearch @@ -484,6 +486,12 @@ struct AppFeature { case .deployScript: return runFirstScript(ofKind: .deploy, state: &state) + case .lintScript: + return runFirstScript(ofKind: .lint, state: &state) + + case .formatScript: + return runFirstScript(ofKind: .format, state: &state) + case .closeTab: guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { return .none diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index cbf77ed6f..4ed1bc6d9 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -230,6 +230,8 @@ struct WorktreeDetailView: View { .focusedSceneValue(\.testScriptAction, actions.testScript) .focusedSceneValue(\.debugScriptAction, actions.debugScript) .focusedSceneValue(\.deployScriptAction, actions.deployScript) + .focusedSceneValue(\.lintScriptAction, actions.lintScript) + .focusedSceneValue(\.formatScriptAction, actions.formatScript) } private func makeFocusedActions( @@ -244,6 +246,8 @@ struct WorktreeDetailView: View { let hasTest = scripts.contains { $0.kind == .test } let hasDebug = scripts.contains { $0.kind == .debug } let hasDeploy = scripts.contains { $0.kind == .deploy } + let hasLint = scripts.contains { $0.kind == .lint } + let hasFormat = scripts.contains { $0.kind == .format } return FocusedActions( openSelectedWorktree: action(.openSelectedWorktree), newTerminal: action(.newTerminal), @@ -259,6 +263,8 @@ struct WorktreeDetailView: View { testScript: hasTest ? { store.send(.testScript) } : nil, debugScript: hasDebug ? { store.send(.debugScript) } : nil, deployScript: hasDeploy ? { store.send(.deployScript) } : nil, + lintScript: hasLint ? { store.send(.lintScript) } : nil, + formatScript: hasFormat ? { store.send(.formatScript) } : nil, ) } @@ -295,6 +301,8 @@ struct WorktreeDetailView: View { let testScript: (() -> Void)? let debugScript: (() -> Void)? let deployScript: (() -> Void)? + let lintScript: (() -> Void)? + let formatScript: (() -> Void)? } fileprivate struct WorktreeToolbarState { diff --git a/supacode/Features/Terminal/Models/TerminalTabTintColor.swift b/supacode/Features/Terminal/Models/TerminalTabTintColor.swift index b7a16bde5..4e0973d5d 100644 --- a/supacode/Features/Terminal/Models/TerminalTabTintColor.swift +++ b/supacode/Features/Terminal/Models/TerminalTabTintColor.swift @@ -1,16 +1,3 @@ -import SupacodeSettingsShared -import SwiftUI - -extension TerminalTabTintColor { - 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 - } - } -} +// TerminalTabTintColor is defined in SupacodeSettingsShared. +// This file previously held the enum before it was moved to the shared module. +// The `var color: Color` property now lives on the enum definition itself. 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/RepositorySettingsScriptTests.swift b/supacodeTests/RepositorySettingsScriptTests.swift index 73bfbcc5f..0a34a7bc8 100644 --- a/supacodeTests/RepositorySettingsScriptTests.swift +++ b/supacodeTests/RepositorySettingsScriptTests.swift @@ -132,13 +132,31 @@ struct RepositorySettingsScriptTests { let store = makeStore() store.exhaustivity = .off(showSkippedAssertions: false) - await store.send(.addScript) { + 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 removeScriptsRemovesAtOffsets() async { let script1 = ScriptDefinition(kind: .run, command: "npm run dev") let script2 = ScriptDefinition(kind: .test, command: "npm test") From d4dd228557092689908f94eb3b7d14877c470633 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 12:13:59 +0200 Subject: [PATCH 08/20] Restructure scripts settings, toolbar labels, and cleanup Remove debug script kind. Add resolvedSystemImage/resolvedTintColor to ScriptDefinition so non-custom scripts always derive visuals from ScriptKind defaults rather than persisted values. Add Image.tintedSymbol helper for colored SF Symbols in macOS menus (auto-resolves .fill variants via AppKit palette colors). Use tinted icons in toolbar script dropdown, plain icons in menu bar. Hide toolbar dropdown chevron when only a .run script is configured. Fix DisclosureGroup: auto-expands on selection, allows manual collapse. Navigate to Scripts settings tab (not General) from toolbar and Cmd+R. Move lifecycle scripts to Scripts tab. Remove non-run/stop keyboard shortcuts entirely (deferred to future iteration). Extend Makefile and swiftlint to cover SupacodeSettingsShared/SupacodeSettingsFeature. --- .swiftlint.yml | 3 + Makefile | 4 +- .../Views/AppearanceOptionCardView.swift | 2 +- .../Views/RepositoryScriptsSettingsView.swift | 182 ++++++++++-------- .../Views/RepositorySettingsView.swift | 43 ----- .../Views/VerticallyCenteredLabelStyle.swift | 16 ++ .../App/AppShortcutOverride.swift | 52 ++--- SupacodeSettingsShared/App/AppShortcuts.swift | 27 +-- .../App/KeyboardShortcut+Display.swift | 10 +- .../BusinessLogic/ClaudeHookSettings.swift | 2 +- .../BusinessLogic/CodexHookSettings.swift | 4 +- .../RepositoryPersistenceKeys.swift | 12 +- .../BusinessLogic/RepositorySettingsKey.swift | 6 +- .../SettingsFilePersistence.swift | 16 +- .../Clients/Analytics/AnalyticsClient.swift | 8 +- .../CodingAgents/ClaudeSettingsClient.swift | 4 +- .../CodingAgents/CodexSettingsClient.swift | 4 +- .../SystemNotificationClient.swift | 4 +- .../ArchivedWorktreeDatesClient.swift | 4 +- .../Clients/Settings/CLIInstallerClient.swift | 4 +- .../Clients/Settings/CLISkillClient.swift | 4 +- .../RepositorySettingsGitClient.swift | 4 +- .../Clients/Shell/ShellClient.swift | 4 +- .../Models/ScriptDefinition.swift | 12 ++ .../Models/ScriptKind.swift | 24 +-- .../Models/TerminalTabTintColor.swift | 14 ++ .../Support/TintedSymbol.swift | 36 ++++ supacode/Commands/WorktreeCommands.swift | 87 +-------- .../Features/App/Reducer/AppFeature.swift | 32 +-- .../Reducer/CommandPaletteFeature.swift | 8 +- .../Views/CommandPaletteOverlayView.swift | 2 +- .../Reducer/RepositoriesFeature.swift | 4 +- .../Views/WorktreeDetailView.swift | 55 ++---- .../Settings/Views/SettingsView.swift | 7 +- .../Terminal/Models/BlockingScriptKind.swift | 4 +- .../Models/WorktreeTerminalState.swift | 2 +- .../AgentHookSettingsFileInstallerTests.swift | 22 +-- supacodeTests/AppFeatureRunScriptTests.swift | 151 +-------------- .../CommandPaletteFeatureTests.swift | 6 +- supacodeTests/RepositoriesFeatureTests.swift | 20 +- .../RepositorySettingsScriptTests.swift | 16 +- .../TerminalLayoutSnapshotTests.swift | 2 +- .../WorktreeTerminalManagerTests.swift | 6 +- 43 files changed, 355 insertions(+), 574 deletions(-) create mode 100644 SupacodeSettingsFeature/Views/VerticallyCenteredLabelStyle.swift create mode 100644 SupacodeSettingsShared/Support/TintedSymbol.swift 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/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 index 9e8119a8e..556288909 100644 --- a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift +++ b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift @@ -2,7 +2,7 @@ import ComposableArchitecture import SupacodeSettingsShared import SwiftUI -/// Settings sub-section for managing on-demand scripts. +/// Settings sub-section for managing on-demand and lifecycle scripts. public struct RepositoryScriptsSettingsView: View { @Bindable var store: StoreOf @@ -12,44 +12,93 @@ public struct RepositoryScriptsSettingsView: View { public var body: some View { Form { + // Lifecycle scripts. Section { - if store.settings.scripts.isEmpty { - Text("No scripts configured.") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 8) - } else { - List { - ForEach($store.settings.scripts) { $script in - ScriptRow(script: $script) - } - .onDelete { offsets in - store.send(.removeScripts(offsets)) - } - .onMove { source, destination in - store.send(.moveScripts(source, destination)) - } + ScriptCommandEditor(text: $store.settings.setupScript, label: "Setup Script") + } header: { + Label { + VStack(alignment: .leading, spacing: 0) { + Text("Setup Script") + .font(.body) + .bold() + .lineLimit(1) + Text("Runs once after worktree creation.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) } - .listStyle(.bordered(alternatesRowBackgrounds: true)) - .frame(minHeight: 120) - } + } icon: { + Image(systemName: "truck.box.badge.clock").foregroundStyle(.blue).accessibilityHidden(true) + }.labelStyle(.verticallyCentered) + } footer: { + Text("e.g., `pnpm install`") + } + + Section { + ScriptCommandEditor(text: $store.settings.archiveScript, label: "Archive Script") } header: { - Text("Scripts") - Text("Launched on demand from the toolbar, command palette, or keyboard shortcut.") + Label { + VStack(alignment: .leading, spacing: 0) { + Text("Archive Script") + .font(.body) + .bold() + .lineLimit(1) + Text("Runs before a worktree is archived.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } icon: { + Image(systemName: "archivebox").foregroundStyle(.orange).accessibilityHidden(true) + }.labelStyle(.verticallyCentered) } footer: { - Text("Drag to reorder. The first script is used as the default.") + Text("e.g., `docker compose down`") } - Section("Environment Variables") { - ScriptEnvironmentRow( - name: "SUPACODE_WORKTREE_PATH", - description: "Path to the active worktree." - ) - ScriptEnvironmentRow( - name: "SUPACODE_ROOT_PATH", - description: "Path to the repository root." - ) + Section { + ScriptCommandEditor(text: $store.settings.deleteScript, label: "Delete Script") + } header: { + Label { + VStack(alignment: .leading, spacing: 0) { + Text("Delete Script") + .font(.body) + .bold() + .lineLimit(1) + Text("Runs before a worktree is deleted.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } icon: { + Image(systemName: "trash").foregroundStyle(.red).accessibilityHidden(true) + }.labelStyle(.verticallyCentered) + } footer: { + Text("e.g., `docker compose down`") } + + // User-defined scripts, each in its own section. + ForEach(Array($store.settings.scripts.enumerated()), id: \.element.id) { index, $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(.removeScripts(IndexSet(integer: index))) + } + .help("Remove this script.") + } header: { + Label { + Text("\(script.name) Script") + .font(.body) + .bold() + } icon: { + Image(systemName: script.resolvedSystemImage).foregroundStyle(script.resolvedTintColor.color) + .accessibilityHidden(true) + }.labelStyle(.verticallyCentered) + } + } + } .formStyle(.grouped) .padding(.top, -20) @@ -57,12 +106,19 @@ public struct RepositoryScriptsSettingsView: View { .padding(.trailing, -6) .toolbar { ToolbarItem(placement: .primaryAction) { + let usedKinds = Set(store.settings.scripts.map(\.kind)) Menu { ForEach(ScriptKind.allCases, id: \.self) { kind in - Button { - store.send(.addScript(kind)) - } label: { - Label(kind.defaultName, systemImage: kind.defaultSystemImage) + 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: { @@ -75,51 +131,17 @@ public struct RepositoryScriptsSettingsView: View { } } -// MARK: - Script row. - -private struct ScriptRow: View { - @Binding var script: ScriptDefinition +/// Monospaced text editor for script commands. +private struct ScriptCommandEditor: View { + @Binding var text: String + let label: String var body: some View { - HStack(spacing: 12) { - Image(systemName: script.systemImage) - .foregroundStyle(script.tintColor.color) - .frame(width: 20) - if script.kind == .custom { - TextField("Name", text: $script.name) - .frame(minWidth: 80) - } else { - Text(script.kind.defaultName) - .frame(minWidth: 80, alignment: .leading) - } - TextField("Command", text: $script.command) - .monospaced() - .frame(minWidth: 120) - } - .padding(.vertical, 4) - } -} - -// MARK: - Environment row. - -private struct ScriptEnvironmentRow: View { - let name: String - let description: String - - var body: some View { - LabeledContent { - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(name, forType: .string) - } label: { - Image(systemName: "doc.on.doc") - .accessibilityLabel("Copy variable key") - } - .buttonStyle(.borderless) - .help("Copy variable key.") - } label: { - Text(name).monospaced() - Text(description) - } + 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 72a3d0b7c..746e7a9d1 100644 --- a/SupacodeSettingsFeature/Views/RepositorySettingsView.swift +++ b/SupacodeSettingsFeature/Views/RepositorySettingsView.swift @@ -108,24 +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: "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) @@ -137,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/VerticallyCenteredLabelStyle.swift b/SupacodeSettingsFeature/Views/VerticallyCenteredLabelStyle.swift new file mode 100644 index 000000000..b6b3a137a --- /dev/null +++ b/SupacodeSettingsFeature/Views/VerticallyCenteredLabelStyle.swift @@ -0,0 +1,16 @@ +import SwiftUI + +/// A label style that arranges the icon and title +/// horizontally with vertical center alignment. +struct VerticallyCenteredLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 6) { + configuration.icon + configuration.title + } + } +} + +extension LabelStyle where Self == VerticallyCenteredLabelStyle { + static var verticallyCentered: VerticallyCenteredLabelStyle { .init() } +} 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 f91965029..2b4406df8 100644 --- a/SupacodeSettingsShared/App/AppShortcuts.swift +++ b/SupacodeSettingsShared/App/AppShortcuts.swift @@ -13,8 +13,6 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case selectWorktree(Int) case openFinder, openRepository, openPullRequest, copyPath case runScript, stopRunScript - case testScript, debugScript, deployScript - case lintScript, formatScript // Stable string key for JSON dictionary persistence. public var codingKey: CodingKey { @@ -55,11 +53,6 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case .copyPath: "copyPath" case .runScript: "runScript" case .stopRunScript: "stopRunScript" - case .testScript: "testScript" - case .debugScript: "debugScript" - case .deployScript: "deployScript" - case .lintScript: "lintScript" - case .formatScript: "formatScript" } } @@ -83,11 +76,6 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep "copyPath": .copyPath, "runScript": .runScript, "stopRunScript": .stopRunScript, - "testScript": .testScript, - "debugScript": .debugScript, - "deployScript": .deployScript, - "lintScript": .lintScript, - "formatScript": .formatScript, ] private init?(stableKey: String) { @@ -124,11 +112,6 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case .copyPath: "Copy Path" case .runScript: "Run Script" case .stopRunScript: "Stop Run Script" - case .testScript: "Test Script" - case .debugScript: "Debug Script" - case .deployScript: "Deploy Script" - case .lintScript: "Lint Script" - case .formatScript: "Format Script" } } } @@ -333,11 +316,6 @@ public enum AppShortcuts { public static let copyPath = AppShortcut(id: .copyPath, key: "c", modifiers: [.command, .shift]) public static let runScript = AppShortcut(id: .runScript, key: "r", modifiers: .command) public static let stopRunScript = AppShortcut(id: .stopRunScript, key: ".", modifiers: .command) - public static let testScript = AppShortcut(id: .testScript, key: "u", modifiers: [.command, .shift]) - public static let debugScript = AppShortcut(id: .debugScript, key: "y", modifiers: [.command, .shift]) - public static let deployScript = AppShortcut(id: .deployScript, key: "d", modifiers: [.command, .shift]) - public static let lintScript = AppShortcut(id: .lintScript, key: "l", modifiers: [.command, .shift]) - public static let formatScript = AppShortcut(id: .formatScript, key: "f", modifiers: [.command, .shift]) public static let worktreeSelection: [AppShortcut] = [ selectWorktree1, selectWorktree2, selectWorktree3, selectWorktree4, selectWorktree5, @@ -361,7 +339,6 @@ public enum AppShortcuts { category: .actions, shortcuts: [ openFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript, - testScript, debugScript, deployScript, lintScript, formatScript, ] ), ] @@ -427,9 +404,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..829c41467 100644 --- a/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift +++ b/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift @@ -94,15 +94,19 @@ public nonisolated struct PinnedWorktreeIDsKey: SharedKey { continuation.resume() } } +nonisolated -public nonisolated extension SharedReaderKey where Self == RepositoryRootsKey.Default { - static var repositoryRoots: Self { + extension SharedReaderKey where Self == RepositoryRootsKey.Default +{ + public static var repositoryRoots: Self { Self[RepositoryRootsKey(), default: []] } } +nonisolated -public nonisolated extension SharedReaderKey where Self == PinnedWorktreeIDsKey.Default { - static var pinnedWorktreeIDs: Self { + 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..c5db83a29 100644 --- a/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift +++ b/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift @@ -87,9 +87,11 @@ public nonisolated struct RepositorySettingsKey: SharedKey { continuation.resume() } } +nonisolated -public nonisolated extension SharedReaderKey where Self == RepositorySettingsKey.Default { - static func repositorySettings(_ rootURL: URL) -> Self { + 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..f62cb7f2f 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,11 @@ public nonisolated struct SettingsFileKey: SharedKey { return encoder } } +nonisolated -public nonisolated extension SharedReaderKey where Self == SettingsFileKey.Default { - static var settingsFile: Self { + 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/ScriptDefinition.swift b/SupacodeSettingsShared/Models/ScriptDefinition.swift index 256534233..959c51f7a 100644 --- a/SupacodeSettingsShared/Models/ScriptDefinition.swift +++ b/SupacodeSettingsShared/Models/ScriptDefinition.swift @@ -17,6 +17,18 @@ public nonisolated struct ScriptDefinition: Identifiable, Codable, Equatable, Ha 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 + } + + /// 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 + } + public nonisolated init( id: UUID = UUID(), kind: ScriptKind, diff --git a/SupacodeSettingsShared/Models/ScriptKind.swift b/SupacodeSettingsShared/Models/ScriptKind.swift index d52b43b98..ad40d4919 100644 --- a/SupacodeSettingsShared/Models/ScriptKind.swift +++ b/SupacodeSettingsShared/Models/ScriptKind.swift @@ -5,7 +5,6 @@ import Foundation /// requires explicit values stored on the owning `ScriptDefinition`. public enum ScriptKind: String, Codable, CaseIterable, Hashable, Sendable { case run - case debug case test case deploy case lint @@ -16,7 +15,6 @@ public enum ScriptKind: String, Codable, CaseIterable, Hashable, Sendable { public nonisolated var defaultName: String { switch self { case .run: "Run" - case .debug: "Debug" case .test: "Test" case .deploy: "Deploy" case .lint: "Lint" @@ -28,13 +26,12 @@ public enum ScriptKind: String, Codable, CaseIterable, Hashable, Sendable { /// Default SF Symbol name for the script kind. public nonisolated var defaultSystemImage: String { switch self { - case .run: "play.fill" - case .debug: "ant.fill" - case .test: "checkmark.diamond.fill" - case .deploy: "arrow.up.circle.fill" - case .lint: "exclamationmark.triangle.fill" - case .format: "text.alignleft" - case .custom: "terminal.fill" + 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" } } @@ -42,12 +39,11 @@ public enum ScriptKind: String, Codable, CaseIterable, Hashable, Sendable { public nonisolated var defaultTintColor: TerminalTabTintColor { switch self { case .run: .green - case .debug: .orange - case .test: .blue - case .deploy: .purple - case .lint: .yellow + case .test: .yellow + case .deploy: .red + case .lint: .blue case .format: .teal - case .custom: .teal + case .custom: .purple } } } diff --git a/SupacodeSettingsShared/Models/TerminalTabTintColor.swift b/SupacodeSettingsShared/Models/TerminalTabTintColor.swift index 93ee421ab..5ea54c436 100644 --- a/SupacodeSettingsShared/Models/TerminalTabTintColor.swift +++ b/SupacodeSettingsShared/Models/TerminalTabTintColor.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// Color token for terminal tab tint indicators, used in place of @@ -23,4 +24,17 @@ public enum TerminalTabTintColor: String, Codable, CaseIterable, Hashable, Senda 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/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 033e91193..dd78dfc15 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -13,11 +13,6 @@ struct WorktreeCommands: Commands { @FocusedValue(\.deleteWorktreeAction) private var deleteWorktreeAction @FocusedValue(\.runScriptAction) private var runScriptAction @FocusedValue(\.stopRunScriptAction) private var stopRunScriptAction - @FocusedValue(\.testScriptAction) private var testScriptAction - @FocusedValue(\.debugScriptAction) private var debugScriptAction - @FocusedValue(\.deployScriptAction) private var deployScriptAction - @FocusedValue(\.lintScriptAction) private var lintScriptAction - @FocusedValue(\.formatScriptAction) private var formatScriptAction @FocusedValue(\.visibleHotkeyWorktreeRows) private var visibleHotkeyWorktreeRows init(store: StoreOf) { @@ -43,11 +38,6 @@ struct WorktreeCommands: Commands { let refresh = AppShortcuts.refreshWorktrees.effective(from: overrides) let run = AppShortcuts.runScript.effective(from: overrides) let stop = AppShortcuts.stopRunScript.effective(from: overrides) - let test = AppShortcuts.testScript.effective(from: overrides) - let debug = AppShortcuts.debugScript.effective(from: overrides) - let deploy = AppShortcuts.deployScript.effective(from: overrides) - let lint = AppShortcuts.lintScript.effective(from: overrides) - let format = AppShortcuts.formatScript.effective(from: overrides) CommandMenu("Worktrees") { // Creation and opening. Button("New Worktree…", systemImage: "plus") { @@ -100,7 +90,7 @@ struct WorktreeCommands: Commands { .disabled(deleteWorktreeAction == nil) Divider() // Scripts. - Button("Run Script", systemImage: "play") { + Button("Run Script", systemImage: ScriptKind.run.defaultSystemImage) { runScriptAction?() } .appKeyboardShortcut(run) @@ -112,36 +102,6 @@ struct WorktreeCommands: Commands { .appKeyboardShortcut(stop) .help("Stop Script (\(stop?.display ?? "none"))") .disabled(stopRunScriptAction == nil) - Button("Test Script", systemImage: "testtube.2") { - testScriptAction?() - } - .appKeyboardShortcut(test) - .help("Test Script (\(test?.display ?? "none"))") - .disabled(testScriptAction == nil) - Button("Debug Script", systemImage: "ladybug") { - debugScriptAction?() - } - .appKeyboardShortcut(debug) - .help("Debug Script (\(debug?.display ?? "none"))") - .disabled(debugScriptAction == nil) - Button("Deploy Script", systemImage: "shippingbox") { - deployScriptAction?() - } - .appKeyboardShortcut(deploy) - .help("Deploy Script (\(deploy?.display ?? "none"))") - .disabled(deployScriptAction == nil) - Button("Lint Script", systemImage: "exclamationmark.triangle") { - lintScriptAction?() - } - .appKeyboardShortcut(lint) - .help("Lint Script (\(lint?.display ?? "none"))") - .disabled(lintScriptAction == nil) - Button("Format Script", systemImage: "text.alignleft") { - formatScriptAction?() - } - .appKeyboardShortcut(format) - .help("Format Script (\(format?.display ?? "none"))") - .disabled(formatScriptAction == nil) Divider() // Navigation. Button("Select Next", systemImage: "chevron.down") { @@ -271,31 +231,6 @@ extension FocusedValues { set { self[StopRunScriptActionKey.self] = newValue } } - var testScriptAction: (() -> Void)? { - get { self[TestScriptActionKey.self] } - set { self[TestScriptActionKey.self] = newValue } - } - - var debugScriptAction: (() -> Void)? { - get { self[DebugScriptActionKey.self] } - set { self[DebugScriptActionKey.self] = newValue } - } - - var deployScriptAction: (() -> Void)? { - get { self[DeployScriptActionKey.self] } - set { self[DeployScriptActionKey.self] = newValue } - } - - var lintScriptAction: (() -> Void)? { - get { self[LintScriptActionKey.self] } - set { self[LintScriptActionKey.self] = newValue } - } - - var formatScriptAction: (() -> Void)? { - get { self[FormatScriptActionKey.self] } - set { self[FormatScriptActionKey.self] = newValue } - } - var visibleHotkeyWorktreeRows: [WorktreeRowModel]? { get { self[VisibleHotkeyWorktreeRowsKey.self] } set { self[VisibleHotkeyWorktreeRowsKey.self] = newValue } @@ -310,26 +245,6 @@ private struct StopRunScriptActionKey: FocusedValueKey { typealias Value = () -> Void } -private struct TestScriptActionKey: FocusedValueKey { - typealias Value = () -> Void -} - -private struct DebugScriptActionKey: FocusedValueKey { - typealias Value = () -> Void -} - -private struct DeployScriptActionKey: FocusedValueKey { - typealias Value = () -> Void -} - -private struct LintScriptActionKey: FocusedValueKey { - typealias Value = () -> Void -} - -private struct FormatScriptActionKey: FocusedValueKey { - typealias Value = () -> Void -} - private struct VisibleHotkeyWorktreeRowsKey: FocusedValueKey { typealias Value = [WorktreeRowModel] } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 7276f0dc3..7394e0b0b 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -74,11 +74,6 @@ struct AppFeature { case runNamedScript(ScriptDefinition) case stopScript(ScriptDefinition) case stopRunScripts - case testScript - case debugScript - case deployScript - case lintScript - case formatScript case closeTab case closeSurface case startSearch @@ -454,7 +449,7 @@ struct AppFeature { var ids = state.repositories.runningScriptsByWorktreeID[worktree.id] ?? [] ids.insert(definition.id) state.repositories.runningScriptsByWorktreeID[worktree.id] = ids - state.repositories.scriptTintColorByID[definition.id] = definition.tintColor + state.repositories.scriptTintColorByID[definition.id] = definition.resolvedTintColor return .run { _ in await terminalClient.send( .runBlockingScript(worktree, kind: .script(definition), script: definition.command) @@ -477,21 +472,6 @@ struct AppFeature { await terminalClient.send(.stopRunScript(worktree)) } - case .testScript: - return runFirstScript(ofKind: .test, state: &state) - - case .debugScript: - return runFirstScript(ofKind: .debug, state: &state) - - case .deployScript: - return runFirstScript(ofKind: .deploy, state: &state) - - case .lintScript: - return runFirstScript(ofKind: .lint, state: &state) - - case .formatScript: - return runFirstScript(ofKind: .format, state: &state) - case .closeTab: guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { return .none @@ -868,16 +848,6 @@ struct AppFeature { } } - // MARK: - Script helpers. - - /// Finds the first script of the given kind and dispatches `.runNamedScript`. - private func runFirstScript(ofKind kind: ScriptKind, state: inout State) -> Effect { - guard let definition = state.scripts.first(where: { $0.kind == kind }) else { - return .none - } - return .send(.runNamedScript(definition)) - } - // MARK: - Deeplink handling. // MARK: Deeplink dispatch. diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 5e00aa58a..5292d5b1a 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -339,7 +339,7 @@ private func pullRequestItems( subtitle: pullRequest.title, kind: .openPullRequest(worktreeID), priorityTier: 2 - ), + ) ] if let readyItem = makeReadyItem() { @@ -633,9 +633,9 @@ private func scriptItems( items.append( CommandPaletteItem( id: CommandPaletteItemID.stopScript(script.id), - title: "Stop: \(script.name)", + title: "Stop: \(script.displayName)", subtitle: nil, - kind: .stopScript(script.id, name: script.name), + kind: .stopScript(script.id, name: script.displayName), priorityTier: 0 ) ) @@ -643,7 +643,7 @@ private func scriptItems( items.append( CommandPaletteItem( id: CommandPaletteItemID.runScript(script.id), - title: "Run: \(script.name)", + title: "Run: \(script.displayName)", subtitle: nil, kind: .runScript(script) ) diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 2b212baca..8ce380cae 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -402,7 +402,7 @@ private struct CommandPaletteRowView: View { case .archiveWorktree: return "archivebox" case .runScript(let definition): - return definition.systemImage + return definition.resolvedSystemImage case .stopScript: return "stop.fill" #if DEBUG diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 8c212abb2..f1a8d22ff 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -2025,7 +2025,7 @@ struct RepositoriesFeature { var effects: [Effect] = [ .run { _ in await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) - }, + } ] if didUpdateWorktreeOrder { let worktreeOrderByRepository = state.worktreeOrderByRepository @@ -2052,7 +2052,7 @@ struct RepositoriesFeature { var effects: [Effect] = [ .run { _ in await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) - }, + } ] if didUpdateWorktreeOrder { let worktreeOrderByRepository = state.worktreeOrderByRepository diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 4ed1bc6d9..b83d6e4b6 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -97,18 +97,15 @@ struct WorktreeDetailView: View { onStopRunScripts: { store.send(.stopRunScripts) }, onManageScripts: { let repositoryID = selectedWorktree.repositoryRootURL.path(percentEncoded: false) - store.send(.settings(.setSelection(.repository(repositoryID)))) + store.send(.settings(.setSelection(.repositoryScripts(repositoryID)))) } ) } } - let hasScripts = !scripts.isEmpty let hasRunningRunScript = state.hasRunningRunScript let actions = makeFocusedActions( hasActiveWorktree: hasActiveWorktree, - hasScripts: hasScripts, - hasRunningRunScript: hasRunningRunScript, - scripts: scripts + hasRunningRunScript: hasRunningRunScript ) return applyFocusedActions(content: content, actions: actions) } @@ -227,27 +224,15 @@ struct WorktreeDetailView: View { .focusedSceneValue(\.endSearchAction, actions.endSearch) .focusedSceneValue(\.runScriptAction, actions.runScript) .focusedSceneValue(\.stopRunScriptAction, actions.stopRunScript) - .focusedSceneValue(\.testScriptAction, actions.testScript) - .focusedSceneValue(\.debugScriptAction, actions.debugScript) - .focusedSceneValue(\.deployScriptAction, actions.deployScript) - .focusedSceneValue(\.lintScriptAction, actions.lintScript) - .focusedSceneValue(\.formatScriptAction, actions.formatScript) } private func makeFocusedActions( hasActiveWorktree: Bool, - hasScripts: Bool, - hasRunningRunScript: Bool, - scripts: [ScriptDefinition] + hasRunningRunScript: Bool ) -> FocusedActions { func action(_ appAction: AppFeature.Action) -> (() -> Void)? { hasActiveWorktree ? { store.send(appAction) } : nil } - let hasTest = scripts.contains { $0.kind == .test } - let hasDebug = scripts.contains { $0.kind == .debug } - let hasDeploy = scripts.contains { $0.kind == .deploy } - let hasLint = scripts.contains { $0.kind == .lint } - let hasFormat = scripts.contains { $0.kind == .format } return FocusedActions( openSelectedWorktree: action(.openSelectedWorktree), newTerminal: action(.newTerminal), @@ -260,11 +245,6 @@ struct WorktreeDetailView: View { endSearch: action(.endSearch), runScript: hasActiveWorktree ? { store.send(.runScript) } : nil, stopRunScript: hasRunningRunScript ? { store.send(.stopRunScripts) } : nil, - testScript: hasTest ? { store.send(.testScript) } : nil, - debugScript: hasDebug ? { store.send(.debugScript) } : nil, - deployScript: hasDeploy ? { store.send(.deployScript) } : nil, - lintScript: hasLint ? { store.send(.lintScript) } : nil, - formatScript: hasFormat ? { store.send(.formatScript) } : nil, ) } @@ -298,11 +278,6 @@ struct WorktreeDetailView: View { let endSearch: (() -> Void)? let runScript: (() -> Void)? let stopRunScript: (() -> Void)? - let testScript: (() -> Void)? - let debugScript: (() -> Void)? - let deployScript: (() -> Void)? - let lintScript: (() -> Void)? - let formatScript: (() -> Void)? } fileprivate struct WorktreeToolbarState { @@ -732,9 +707,9 @@ private struct ScriptSplitButton: View { onRunScript() } label: { HStack(spacing: 6) { - Image(systemName: primaryScript.systemImage) + Image(systemName: primaryScript.resolvedSystemImage) .accessibilityHidden(true) - Text(primaryScript.name) + Text(primaryScript.displayName) if commandKeyObserver.isPressed { Text(shortcutDisplay(for: AppShortcuts.runScript, fallback: "")) .font(.caption) @@ -766,7 +741,11 @@ private struct ScriptSplitButton: View { @ViewBuilder private var scriptMenu: some View { - if !toolbarState.scripts.isEmpty { + // Show the dropdown unless the only script is the primary .run script. + let showMenu = + toolbarState.scripts.count > 1 + || (toolbarState.scripts.count == 1 && toolbarState.scripts.first?.kind != .run) + if showMenu { Menu { ForEach(toolbarState.scripts) { script in let isRunning = toolbarState.runningScriptIDs.contains(script.id) @@ -777,12 +756,16 @@ private struct ScriptSplitButton: View { onRunNamedScript(script) } } label: { - Label( - isRunning ? "Stop: \(script.name)" : "Run: \(script.name)", - systemImage: isRunning ? "stop.fill" : script.systemImage - ) + Label { + Text(isRunning ? "Stop \(script.displayName)" : script.displayName) + } icon: { + Image.tintedSymbol( + isRunning ? "stop.fill" : script.resolvedSystemImage, + color: script.resolvedTintColor.nsColor, + ) + } } - .help(isRunning ? "Stop \(script.name)" : "Run \(script.name)") + .help(isRunning ? "Stop \(script.displayName)" : "Run \(script.displayName)") } Divider() Button("Manage Scripts…") { diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 915618aef..8a412f6cf 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -79,7 +79,7 @@ struct SettingsView: View { ForEach(settingsStore.repositorySummaries, id: \.id) { repository in DisclosureGroup( isExpanded: Binding( - get: { expandedRepositories.contains(repository.id) || selection.repositoryID == repository.id }, + get: { expandedRepositories.contains(repository.id) }, set: { expanded in if expanded { expandedRepositories.insert(repository.id) @@ -103,6 +103,11 @@ struct SettingsView: View { .frame(minWidth: 220, maxHeight: .infinity) .navigationSplitViewColumnWidth(220) .toolbar(removing: .sidebarToggle) + .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) + } } detail: { switch selection { case .general: diff --git a/supacode/Features/Terminal/Models/BlockingScriptKind.swift b/supacode/Features/Terminal/Models/BlockingScriptKind.swift index 41bb80fed..88fb387f4 100644 --- a/supacode/Features/Terminal/Models/BlockingScriptKind.swift +++ b/supacode/Features/Terminal/Models/BlockingScriptKind.swift @@ -20,7 +20,7 @@ enum BlockingScriptKind: Hashable, Sendable { var tabIcon: String { switch self { - case .script(let definition): definition.systemImage + case .script(let definition): definition.resolvedSystemImage case .archive: "archivebox.fill" case .delete: "trash.fill" } @@ -28,7 +28,7 @@ enum BlockingScriptKind: Hashable, Sendable { var tabColor: TerminalTabTintColor { switch self { - case .script(let definition): definition.tintColor + case .script(let definition): definition.resolvedTintColor case .archive: .orange case .delete: .red } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index e3f0d4905..fd05fb766 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, 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/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index 50a134ddb..02ae49eed 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -16,7 +16,7 @@ struct AppFeatureRunScriptTests { let expectedRepositoryID = worktree.repositoryRootURL.path(percentEncoded: false) var settingsState = SettingsFeature.State() settingsState.repositorySummaries = [ - SettingsRepositorySummary(id: expectedRepositoryID, name: "repo"), + SettingsRepositorySummary(id: expectedRepositoryID, name: "repo") ] let store = TestStore( initialState: AppFeature.State( @@ -54,7 +54,7 @@ struct AppFeatureRunScriptTests { await store.send(.runScript) await store.receive(\.runNamedScript) { $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - $0.repositories.scriptTintColorByID = [definition.id: definition.tintColor] + $0.repositories.scriptTintColorByID = [definition.id: definition.resolvedTintColor] } await store.finish() @@ -90,72 +90,18 @@ struct AppFeatureRunScriptTests { await store.send(.runNamedScript(definition)) { $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - $0.repositories.scriptTintColorByID = [definition.id: definition.tintColor] + $0.repositories.scriptTintColorByID = [definition.id: definition.resolvedTintColor] } await store.finish() } - @Test(.dependencies) func testScriptRunsFirstTestKindScript() async { - let worktree = makeWorktree() - let repositories = makeRepositoriesState(worktree: worktree) - let testScript = ScriptDefinition(kind: .test, name: "Test", command: "npm test") - let runScript = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") - let sent = LockIsolated<[TerminalClient.Command]>([]) - var initialState = AppFeature.State( - repositories: repositories, - settings: SettingsFeature.State() - ) - initialState.scripts = [runScript, testScript] - let store = TestStore(initialState: initialState) { - AppFeature() - } withDependencies: { - $0.terminalClient.send = { command in - sent.withValue { $0.append(command) } - } - } - - await store.send(.testScript) - await store.receive(\.runNamedScript) { - $0.repositories.runningScriptsByWorktreeID = [worktree.id: [testScript.id]] - $0.repositories.scriptTintColorByID = [testScript.id: testScript.tintColor] - } - await store.finish() - - #expect(sent.value.count == 1) - guard case .runBlockingScript(_, let kind, let script) = sent.value.first else { - Issue.record("Expected runBlockingScript command") - return - } - #expect(script == "npm test") - guard case .script(let def) = kind else { - Issue.record("Expected .script kind") - return - } - #expect(def.kind == .test) - } - - @Test(.dependencies) func testScriptWithNoTestScriptDoesNothing() async { - let worktree = makeWorktree() - let repositories = makeRepositoriesState(worktree: worktree) - var initialState = AppFeature.State( - repositories: repositories, - settings: SettingsFeature.State() - ) - initialState.scripts = [ScriptDefinition(kind: .run, command: "npm start")] - let store = TestStore(initialState: initialState) { - AppFeature() - } - - await store.send(.testScript) - } - @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]] - repositoriesState.scriptTintColorByID = [definition.id: definition.tintColor] + repositoriesState.scriptTintColorByID = [definition.id: definition.resolvedTintColor] let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -239,95 +185,6 @@ struct AppFeatureRunScriptTests { #expect(definitionID == definition.id) } - @Test(.dependencies) func debugScriptRunsFirstDebugKindScript() async { - let worktree = makeWorktree() - let repositories = makeRepositoriesState(worktree: worktree) - let debugDef = ScriptDefinition(kind: .debug, name: "Debug", command: "lldb app") - let sent = LockIsolated<[TerminalClient.Command]>([]) - var initialState = AppFeature.State( - repositories: repositories, - settings: SettingsFeature.State(), - ) - initialState.scripts = [debugDef] - let store = TestStore(initialState: initialState) { - AppFeature() - } withDependencies: { - $0.terminalClient.send = { command in - sent.withValue { $0.append(command) } - } - } - - await store.send(.debugScript) - await store.receive(\.runNamedScript) { - $0.repositories.runningScriptsByWorktreeID = [worktree.id: [debugDef.id]] - $0.repositories.scriptTintColorByID = [debugDef.id: debugDef.tintColor] - } - await store.finish() - - #expect(sent.value.count == 1) - guard case .runBlockingScript(_, let kind, _) = sent.value.first else { - Issue.record("Expected runBlockingScript command") - return - } - guard case .script(let def) = kind else { - Issue.record("Expected .script kind") - return - } - #expect(def.kind == .debug) - } - - @Test(.dependencies) func deployScriptRunsFirstDeployKindScript() async { - let worktree = makeWorktree() - let repositories = makeRepositoriesState(worktree: worktree) - let deployDef = ScriptDefinition(kind: .deploy, name: "Deploy", command: "./deploy.sh") - let sent = LockIsolated<[TerminalClient.Command]>([]) - var initialState = AppFeature.State( - repositories: repositories, - settings: SettingsFeature.State(), - ) - initialState.scripts = [deployDef] - let store = TestStore(initialState: initialState) { - AppFeature() - } withDependencies: { - $0.terminalClient.send = { command in - sent.withValue { $0.append(command) } - } - } - - await store.send(.deployScript) - await store.receive(\.runNamedScript) { - $0.repositories.runningScriptsByWorktreeID = [worktree.id: [deployDef.id]] - $0.repositories.scriptTintColorByID = [deployDef.id: deployDef.tintColor] - } - await store.finish() - - #expect(sent.value.count == 1) - guard case .runBlockingScript(_, let kind, _) = sent.value.first else { - Issue.record("Expected runBlockingScript command") - return - } - guard case .script(let def) = kind else { - Issue.record("Expected .script kind") - return - } - #expect(def.kind == .deploy) - } - - @Test(.dependencies) func debugScriptWithNoDebugScriptDoesNothing() async { - let worktree = makeWorktree() - let repositories = makeRepositoriesState(worktree: worktree) - var initialState = AppFeature.State( - repositories: repositories, - settings: SettingsFeature.State(), - ) - initialState.scripts = [ScriptDefinition(kind: .run, command: "npm start")] - let store = TestStore(initialState: initialState) { - AppFeature() - } - - await store.send(.debugScript) - } - @Test(.dependencies) func worktreeSettingsLoadedPopulatesScripts() async { let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 54eefb41c..08e8ba3db 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -49,7 +49,7 @@ struct CommandPaletteFeatureTests { copyIgnored: false, copyUntracked: false ) - ), + ) ] let items = CommandPaletteFeature.commandPaletteItems(from: state) @@ -74,7 +74,7 @@ struct CommandPaletteFeatureTests { description: "Focus the split to the right.", action: "goto_split:right", actionKey: "goto_split" - ), + ) ] ) @@ -98,7 +98,7 @@ struct CommandPaletteFeatureTests { description: "", action: "goto_split:right", actionKey: "goto_split" - ), + ) ] ) diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 9479682ed..153210bd7 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) { @@ -1791,7 +1791,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - state.scriptTintColorByID = [definition.id: definition.tintColor] + state.scriptTintColorByID = [definition.id: definition.resolvedTintColor] let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -1824,7 +1824,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - state.scriptTintColorByID = [definition.id: definition.tintColor] + state.scriptTintColorByID = [definition.id: definition.resolvedTintColor] let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -1851,7 +1851,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - state.scriptTintColorByID = [definition.id: definition.tintColor] + state.scriptTintColorByID = [definition.id: definition.resolvedTintColor] let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -1880,7 +1880,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - state.scriptTintColorByID = [definition.id: definition.tintColor] + state.scriptTintColorByID = [definition.id: definition.resolvedTintColor] let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -2987,7 +2987,7 @@ struct RepositoriesFeatureTests { id: removedWorktree.id, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .choosingWorktreeName) - ), + ) ] initialState.pinnedWorktreeIDs = [removedWorktree.id] initialState.worktreeInfoByID = [ @@ -3070,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/RepositorySettingsScriptTests.swift b/supacodeTests/RepositorySettingsScriptTests.swift index 0a34a7bc8..f49b43472 100644 --- a/supacodeTests/RepositorySettingsScriptTests.swift +++ b/supacodeTests/RepositorySettingsScriptTests.swift @@ -37,7 +37,12 @@ struct RepositorySettingsCodableTests { "deleteScript": "", "runScript": "legacy command", "scripts": [ - {"id": "00000000-0000-0000-0000-000000000001", "kind": "test", "name": "Test", "systemImage": "checkmark.diamond.fill", "tintColor": "blue", "command": "npm test"} + { + "id": "00000000-0000-0000-0000-000000000001", + "kind": "test", "name": "Test", + "systemImage": "checkmark.diamond.fill", + "tintColor": "blue", "command": "npm test" + } ], "openActionID": "automatic" } @@ -72,7 +77,12 @@ struct RepositorySettingsCodableTests { "deleteScript": "", "runScript": "", "scripts": [ - {"id": "00000000-0000-0000-0000-000000000001", "kind": "unknown_future_kind", "name": "X", "systemImage": "star", "tintColor": "red", "command": "echo hi"} + { + "id": "00000000-0000-0000-0000-000000000001", + "kind": "unknown_future_kind", "name": "X", + "systemImage": "star", + "tintColor": "red", "command": "echo hi" + } ], "openActionID": "automatic" } @@ -160,7 +170,7 @@ struct RepositorySettingsScriptTests { @Test(.dependencies) func removeScriptsRemovesAtOffsets() async { let script1 = ScriptDefinition(kind: .run, command: "npm run dev") let script2 = ScriptDefinition(kind: .test, command: "npm test") - let script3 = ScriptDefinition(kind: .debug, command: "lldb") + let script3 = ScriptDefinition(kind: .deploy, command: "deploy.sh") let store = makeStore(scripts: [script1, script2, script3]) store.exhaustivity = .off(showSkippedAssertions: false) 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/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index e61e63666..881a7ce99 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -167,7 +167,7 @@ struct WorktreeTerminalManagerTests { title: "Unread", body: "body", isRead: false - ), + ) ] state.onNotificationIndicatorChanged?() state.notifications = [ @@ -176,7 +176,7 @@ struct WorktreeTerminalManagerTests { title: "Read", body: "body", isRead: true - ), + ) ] let stream = manager.eventStream() @@ -845,7 +845,7 @@ struct WorktreeTerminalManagerTests { ) ), focusedLeafIndex: 0 - ), + ) ], selectedTabIndex: 0 ) From 1dc6b7d189453f7e18d9c79a8e6907fde41845d4 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 12:55:30 +0200 Subject: [PATCH 09/20] Fix decode resilience, dedup, tests, and cleanup --- .../Models/RepositorySettings.swift | 28 +++++- .../Models/ScriptDefinition.swift | 15 ++-- .../VerticallyCenteredLabelStyle.swift | 8 +- .../Features/App/Reducer/AppFeature.swift | 11 +-- .../Reducer/CommandPaletteFeature.swift | 7 +- .../Repositories/Views/WorktreeRow.swift | 15 ---- supacodeTests/AppFeatureRunScriptTests.swift | 36 ++++++++ .../CommandPaletteFeatureTests.swift | 87 +++++++++++++++++++ .../RepositorySettingsScriptTests.swift | 24 ++++- 9 files changed, 194 insertions(+), 37 deletions(-) rename {SupacodeSettingsFeature/Views => SupacodeSettingsShared/Support}/VerticallyCenteredLabelStyle.swift (54%) diff --git a/SupacodeSettingsShared/Models/RepositorySettings.swift b/SupacodeSettingsShared/Models/RepositorySettings.swift index e2c4c9f6d..aee3e3f60 100644 --- a/SupacodeSettingsShared/Models/RepositorySettings.swift +++ b/SupacodeSettingsShared/Models/RepositorySettings.swift @@ -83,9 +83,9 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { ?? Self.default.runScript // Migrate legacy `runScript` into the new `scripts` array when // the `scripts` key is absent from persisted JSON. - // Use `try?` so an unknown `ScriptKind` raw value from a future - // version doesn't crash the entire settings decode. - let decodedScripts = try? container.decodeIfPresent([ScriptDefinition].self, forKey: .scripts) + // 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 { @@ -128,4 +128,26 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { 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). + 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 { + return nil + } + 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 index 959c51f7a..fe4c6d13c 100644 --- a/SupacodeSettingsShared/Models/ScriptDefinition.swift +++ b/SupacodeSettingsShared/Models/ScriptDefinition.swift @@ -7,10 +7,13 @@ public nonisolated struct ScriptDefinition: Identifiable, Codable, Equatable, Ha public var id: UUID public var kind: ScriptKind public var name: String - public var systemImage: String - public var tintColor: TerminalTabTintColor 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 { @@ -20,13 +23,13 @@ public nonisolated struct ScriptDefinition: Identifiable, Codable, Equatable, Ha /// 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 + systemImage ?? 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 + tintColor ?? kind.defaultTintColor } public nonisolated init( @@ -40,8 +43,8 @@ public nonisolated struct ScriptDefinition: Identifiable, Codable, Equatable, Ha self.id = id self.kind = kind self.name = name ?? kind.defaultName - self.systemImage = systemImage ?? kind.defaultSystemImage - self.tintColor = tintColor ?? kind.defaultTintColor + self.systemImage = systemImage + self.tintColor = tintColor self.command = command } } diff --git a/SupacodeSettingsFeature/Views/VerticallyCenteredLabelStyle.swift b/SupacodeSettingsShared/Support/VerticallyCenteredLabelStyle.swift similarity index 54% rename from SupacodeSettingsFeature/Views/VerticallyCenteredLabelStyle.swift rename to SupacodeSettingsShared/Support/VerticallyCenteredLabelStyle.swift index b6b3a137a..0a37f3aec 100644 --- a/SupacodeSettingsFeature/Views/VerticallyCenteredLabelStyle.swift +++ b/SupacodeSettingsShared/Support/VerticallyCenteredLabelStyle.swift @@ -2,8 +2,10 @@ import SwiftUI /// A label style that arranges the icon and title /// horizontally with vertical center alignment. -struct VerticallyCenteredLabelStyle: LabelStyle { - func makeBody(configuration: Configuration) -> some View { +public struct VerticallyCenteredLabelStyle: LabelStyle { + public init() {} + + public func makeBody(configuration: Configuration) -> some View { HStack(spacing: 6) { configuration.icon configuration.title @@ -12,5 +14,5 @@ struct VerticallyCenteredLabelStyle: LabelStyle { } extension LabelStyle where Self == VerticallyCenteredLabelStyle { - static var verticallyCentered: VerticallyCenteredLabelStyle { .init() } + public static var verticallyCentered: VerticallyCenteredLabelStyle { .init() } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 7394e0b0b..6e38e2468 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -215,7 +215,10 @@ struct AppFeature { let remainingScriptIDs = Set(state.repositories.runningScriptsByWorktreeID.values.flatMap { $0 }) state.repositories.scriptTintColorByID = state.repositories.scriptTintColorByID .filter { remainingScriptIDs.contains($0.key) } - let recencyIDs = CommandPaletteFeature.recencyRetentionIDs(from: repositories) + let recencyIDs = CommandPaletteFeature.recencyRetentionIDs( + from: repositories, + scripts: state.scripts + ) let worktrees = state.repositories.worktreesForInfoWatcher() var effects: [Effect] = [ .send( @@ -752,12 +755,10 @@ struct AppFeature { return .send(.runNamedScript(definition)) case .commandPalette(.delegate(.stopScript(let scriptID, _))): - guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { + guard let definition = state.scripts.first(where: { $0.id == scriptID }) else { return .none } - return .run { _ in - await terminalClient.send(.stopScript(worktree, definitionID: scriptID)) - } + return .send(.stopScript(definition)) #if DEBUG case .commandPalette(.delegate(.debugTestToast(let toast))): diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 5292d5b1a..eef75ca90 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -245,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 { @@ -254,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 } } diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index 702ae4fe0..7ff16f83d 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -402,21 +402,6 @@ private struct StatusIndicator: View { } } -// MARK: - Vertically centered label style. - -private struct VerticallyCenteredLabelStyle: LabelStyle { - func makeBody(configuration: Configuration) -> some View { - HStack(spacing: 6) { - configuration.icon - configuration.title - } - } -} - -extension LabelStyle where Self == VerticallyCenteredLabelStyle { - static var verticallyCentered: VerticallyCenteredLabelStyle { .init() } -} - // MARK: - Multi-color ping dot. /// Displays a pulsing dot that cycles through multiple script tint diff --git a/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index 02ae49eed..6712cb5e3 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -205,6 +205,42 @@ struct AppFeatureRunScriptTests { #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]] + repositoriesState.scriptTintColorByID = [definition.id: definition.resolvedTintColor] + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } + // 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 = [:] + $0.repositories.scriptTintColorByID = [:] + } + } + private func makeWorktree() -> Worktree { Worktree( id: "/tmp/repo/wt-1", diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 08e8ba3db..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 @@ -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/RepositorySettingsScriptTests.swift b/supacodeTests/RepositorySettingsScriptTests.swift index f49b43472..3389d7201 100644 --- a/supacodeTests/RepositorySettingsScriptTests.swift +++ b/supacodeTests/RepositorySettingsScriptTests.swift @@ -67,9 +67,9 @@ struct RepositorySettingsCodableTests { #expect(raw["runScript"]?.stringValue == "npm run dev") } - @Test func decodeWithUnknownScriptKindFallsBackGracefully() throws { - // An unknown `kind` value should not crash; scripts should fall - // back to an empty array. + @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": "", @@ -79,9 +79,21 @@ struct RepositorySettingsCodableTests { "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" @@ -89,7 +101,11 @@ struct RepositorySettingsCodableTests { """ let data = Data(json.utf8) let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) - #expect(settings.scripts.isEmpty) + #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") } } From c52fe57d2f7e6855b809322375c70fc89c4da233 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 13:13:13 +0200 Subject: [PATCH 10/20] Fix opening brace lint violations in persistence extensions --- .../BusinessLogic/RepositoryPersistenceKeys.swift | 9 ++------- .../BusinessLogic/RepositorySettingsKey.swift | 5 +---- .../BusinessLogic/SettingsFilePersistence.swift | 5 +---- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift b/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift index 829c41467..874e50f64 100644 --- a/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift +++ b/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift @@ -94,18 +94,13 @@ public nonisolated struct PinnedWorktreeIDsKey: SharedKey { continuation.resume() } } -nonisolated - - extension SharedReaderKey where Self == RepositoryRootsKey.Default -{ +nonisolated extension SharedReaderKey where Self == RepositoryRootsKey.Default { public static var repositoryRoots: Self { Self[RepositoryRootsKey(), default: []] } } -nonisolated - extension SharedReaderKey where Self == PinnedWorktreeIDsKey.Default -{ +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 c5db83a29..6fadb94e2 100644 --- a/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift +++ b/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift @@ -87,10 +87,7 @@ public nonisolated struct RepositorySettingsKey: SharedKey { continuation.resume() } } -nonisolated - - extension SharedReaderKey where Self == RepositorySettingsKey.Default -{ +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 f62cb7f2f..81c111c7a 100644 --- a/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift +++ b/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift @@ -154,10 +154,7 @@ public nonisolated struct SettingsFileKey: SharedKey { return encoder } } -nonisolated - - extension SharedReaderKey where Self == SettingsFileKey.Default -{ +nonisolated extension SharedReaderKey where Self == SettingsFileKey.Default { public static var settingsFile: Self { Self[SettingsFileKey(), default: .default] } From 6a8d39273e9fe38f20413bbe036fe3167f67fc9a Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 14:01:52 +0200 Subject: [PATCH 11/20] Fix identity, kind defaults, and dead code cleanup --- .../Reducer/RepositorySettingsFeature.swift | 19 ++---------- .../Views/RepositoryScriptsSettingsView.swift | 4 +-- .../Models/ScriptDefinition.swift | 18 +++++++++-- .../Features/App/Reducer/AppFeature.swift | 5 ++- .../Views/WorktreeDetailView.swift | 4 +-- .../Terminal/Models/BlockingScriptKind.swift | 31 ++++++++++++++++++- .../Models/TerminalTabTintColor.swift | 3 -- .../RepositorySettingsScriptTests.swift | 31 ++----------------- 8 files changed, 57 insertions(+), 58 deletions(-) delete mode 100644 supacode/Features/Terminal/Models/TerminalTabTintColor.swift diff --git a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift index 502a6f7a2..45a422656 100644 --- a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift @@ -64,8 +64,7 @@ public struct RepositorySettingsFeature { ) case branchDataLoaded([String], defaultBaseRef: String) case addScript(ScriptKind) - case removeScripts(IndexSet) - case moveScripts(IndexSet, Int) + case removeScript(ScriptDefinition.ID) case delegate(Delegate) case binding(BindingAction) } @@ -171,20 +170,8 @@ public struct RepositorySettingsFeature { state.settings.scripts.append(ScriptDefinition(kind: kind)) return persistAndNotify(state: &state) - case .removeScripts(let offsets): - var scripts = state.settings.scripts - for index in offsets.sorted().reversed() { - scripts.remove(at: index) - } - state.settings.scripts = scripts - return persistAndNotify(state: &state) - - case .moveScripts(let source, let destination): - var scripts = state.settings.scripts - let items = source.sorted().reversed().map { scripts.remove(at: $0) }.reversed() - let insertAt = min(destination, scripts.count) - scripts.insert(contentsOf: items, at: insertAt) - state.settings.scripts = scripts + case .removeScript(let id): + state.settings.scripts.removeAll { $0.id == id } return persistAndNotify(state: &state) case .binding: diff --git a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift index 556288909..57294dc14 100644 --- a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift +++ b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift @@ -77,14 +77,14 @@ public struct RepositoryScriptsSettingsView: View { } // User-defined scripts, each in its own section. - ForEach(Array($store.settings.scripts.enumerated()), id: \.element.id) { index, $script in + 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(.removeScripts(IndexSet(integer: index))) + store.send(.removeScript(script.id)) } .help("Remove this script.") } header: { diff --git a/SupacodeSettingsShared/Models/ScriptDefinition.swift b/SupacodeSettingsShared/Models/ScriptDefinition.swift index fe4c6d13c..ede212e92 100644 --- a/SupacodeSettingsShared/Models/ScriptDefinition.swift +++ b/SupacodeSettingsShared/Models/ScriptDefinition.swift @@ -23,13 +23,13 @@ public nonisolated struct ScriptDefinition: Identifiable, Codable, Equatable, Ha /// Resolved SF Symbol name: predefined types always use the kind /// default so future icon changes propagate automatically. public nonisolated var resolvedSystemImage: String { - systemImage ?? kind.defaultSystemImage + 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 { - tintColor ?? kind.defaultTintColor + kind == .custom ? (tintColor ?? kind.defaultTintColor) : kind.defaultTintColor } public nonisolated init( @@ -48,3 +48,17 @@ public nonisolated struct ScriptDefinition: Identifiable, Codable, Equatable, Ha 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/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 6e38e2468..eb7d05587 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -40,7 +40,7 @@ struct AppFeature { /// The script that the primary toolbar button should run. var primaryScript: ScriptDefinition? { - scripts.first { $0.kind == .run } + scripts.primaryScript } /// Running script IDs for the currently selected worktree. @@ -51,8 +51,7 @@ struct AppFeature { /// Whether any `.run`-kind script is currently running in the selected worktree. var hasRunningRunScript: Bool { - let running = runningScriptIDs - return scripts.contains { $0.kind == .run && running.contains($0.id) } + scripts.hasRunningRunScript(in: runningScriptIDs) } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index b83d6e4b6..57a62d2f6 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -293,12 +293,12 @@ struct WorktreeDetailView: View { /// The script the primary toolbar button should run (always the `.run` script). var primaryScript: ScriptDefinition? { - scripts.first { $0.kind == .run } + scripts.primaryScript } /// Whether any `.run`-kind script is currently running. var hasRunningRunScript: Bool { - scripts.contains { $0.kind == .run && runningScriptIDs.contains($0.id) } + scripts.hasRunningRunScript(in: runningScriptIDs) } var runScriptHelpText: String { diff --git a/supacode/Features/Terminal/Models/BlockingScriptKind.swift b/supacode/Features/Terminal/Models/BlockingScriptKind.swift index 88fb387f4..954e69e19 100644 --- a/supacode/Features/Terminal/Models/BlockingScriptKind.swift +++ b/supacode/Features/Terminal/Models/BlockingScriptKind.swift @@ -5,7 +5,11 @@ import SupacodeSettingsShared /// 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. -enum BlockingScriptKind: Hashable, Sendable { +/// +/// 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 @@ -50,3 +54,28 @@ enum BlockingScriptKind: Hashable, Sendable { } } } + +// 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/TerminalTabTintColor.swift b/supacode/Features/Terminal/Models/TerminalTabTintColor.swift deleted file mode 100644 index 4e0973d5d..000000000 --- a/supacode/Features/Terminal/Models/TerminalTabTintColor.swift +++ /dev/null @@ -1,3 +0,0 @@ -// TerminalTabTintColor is defined in SupacodeSettingsShared. -// This file previously held the enum before it was moved to the shared module. -// The `var color: Color` property now lives on the enum definition itself. diff --git a/supacodeTests/RepositorySettingsScriptTests.swift b/supacodeTests/RepositorySettingsScriptTests.swift index 3389d7201..dcc79fe12 100644 --- a/supacodeTests/RepositorySettingsScriptTests.swift +++ b/supacodeTests/RepositorySettingsScriptTests.swift @@ -183,45 +183,18 @@ struct RepositorySettingsScriptTests { } } - @Test(.dependencies) func removeScriptsRemovesAtOffsets() async { + @Test(.dependencies) func removeScriptRemovesByID() 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(.removeScripts(IndexSet(integer: 1))) { + await store.send(.removeScript(script2.id)) { #expect($0.settings.scripts.count == 2) #expect($0.settings.scripts[0].id == script1.id) #expect($0.settings.scripts[1].id == script3.id) } } - @Test(.dependencies) func moveScriptsReordersCorrectly() 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) - - // Move the last item to the beginning. - await store.send(.moveScripts(IndexSet(integer: 2), 0)) { - #expect($0.settings.scripts[0].id == script3.id) - #expect($0.settings.scripts[1].id == script1.id) - #expect($0.settings.scripts[2].id == script2.id) - } - } - - @Test(.dependencies) func moveScriptsToEnd() async { - let script1 = ScriptDefinition(kind: .run, command: "npm run dev") - let script2 = ScriptDefinition(kind: .test, command: "npm test") - let store = makeStore(scripts: [script1, script2]) - store.exhaustivity = .off(showSkippedAssertions: false) - - // Move the first item past the end. - await store.send(.moveScripts(IndexSet(integer: 0), 2)) { - #expect($0.settings.scripts[0].id == script2.id) - #expect($0.settings.scripts[1].id == script1.id) - } - } } From f193e55e62138b8f9ea2523fc6502ae22942181b Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 14:52:02 +0200 Subject: [PATCH 12/20] Remove tint color cache, fix displayName, encode fallback, DRY - Remove `scriptTintColorByID` side-cache from RepositoriesFeature; resolve colors from current script definitions via environment. - Use `displayName` in BlockingScriptKind.tabTitle and settings header. - Encode `runScript` as empty string when no .run scripts exist. - Distinguish absent vs corrupted `scripts` key in lossy decoder. - Document `runScript` as legacy backward-compat field. - Extract duplicated `shortcutDisplay` into shared free function. - Add test for encode fallback with no .run-kind script. - Update RepositorySettingsKeyTests to use setupScript for round-trips. --- .../Views/RepositoryScriptsSettingsView.swift | 2 +- .../Models/RepositorySettings.swift | 16 +++++++++--- supacode/App/ContentView.swift | 13 ++++++++++ .../Features/App/Reducer/AppFeature.swift | 8 +++--- .../Reducer/RepositoriesFeature.swift | 22 ++++++---------- .../Views/WorktreeDetailView.swift | 25 ++++++++----------- .../Repositories/Views/WorktreeRowsView.swift | 3 ++- .../Terminal/Models/BlockingScriptKind.swift | 2 +- .../AppFeatureArchivedSelectionTests.swift | 1 - supacodeTests/AppFeatureRunScriptTests.swift | 12 ++++----- supacodeTests/RepositoriesFeatureTests.swift | 16 ++++++------ .../RepositorySettingsKeyTests.swift | 14 +++++------ .../RepositorySettingsScriptTests.swift | 13 ++++++++++ 13 files changed, 86 insertions(+), 61 deletions(-) diff --git a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift index 57294dc14..e8b609b09 100644 --- a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift +++ b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift @@ -89,7 +89,7 @@ public struct RepositoryScriptsSettingsView: View { .help("Remove this script.") } header: { Label { - Text("\(script.name) Script") + Text("\(script.displayName) Script") .font(.body) .bold() } icon: { diff --git a/SupacodeSettingsShared/Models/RepositorySettings.swift b/SupacodeSettingsShared/Models/RepositorySettings.swift index aee3e3f60..55d0c08aa 100644 --- a/SupacodeSettingsShared/Models/RepositorySettings.swift +++ b/SupacodeSettingsShared/Models/RepositorySettings.swift @@ -4,6 +4,9 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { public var setupScript: String public var archiveScript: String public var deleteScript: 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 var runScript: String public var scripts: [ScriptDefinition] public var openActionID: String @@ -118,7 +121,10 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { try container.encode(deleteScript, forKey: .deleteScript) // Derive `runScript` from the first `.run`-kind script's command // so older clients can still read the value. - let derivedRunScript = scripts.first(where: { $0.kind == .run })?.command ?? runScript + // 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) @@ -131,13 +137,17 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { /// 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). + /// 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 { - return nil + // 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 } } diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 183d533c3..2c6a644dc 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -6,6 +6,7 @@ // import ComposableArchitecture +import SupacodeSettingsShared import SwiftUI import UniformTypeIdentifiers @@ -32,6 +33,7 @@ struct ContentView: View { } .navigationSplitViewStyle(.automatic) .disabled(!store.repositories.isInitialLoadComplete) + .environment(\.scripts, store.scripts) .environment(\.surfaceBackgroundOpacity, terminalManager.surfaceBackgroundOpacity()) .onChange(of: scenePhase) { _, newValue in store.send(.scenePhaseChanged(newValue)) @@ -103,6 +105,17 @@ struct ContentView: View { } +private struct ScriptsEnvironmentKey: EnvironmentKey { + static let defaultValue: [ScriptDefinition] = [] +} + +extension EnvironmentValues { + var scripts: [ScriptDefinition] { + get { self[ScriptsEnvironmentKey.self] } + set { self[ScriptsEnvironmentKey.self] = newValue } + } +} + private struct SurfaceBackgroundOpacityKey: EnvironmentKey { static let defaultValue: Double = 1 } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index eb7d05587..c597bcbab 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -211,9 +211,6 @@ struct AppFeature { ) state.repositories.runningScriptsByWorktreeID = state.repositories.runningScriptsByWorktreeID .filter { ids.contains($0.key) } - let remainingScriptIDs = Set(state.repositories.runningScriptsByWorktreeID.values.flatMap { $0 }) - state.repositories.scriptTintColorByID = state.repositories.scriptTintColorByID - .filter { remainingScriptIDs.contains($0.key) } let recencyIDs = CommandPaletteFeature.recencyRetentionIDs( from: repositories, scripts: state.scripts @@ -451,7 +448,6 @@ struct AppFeature { var ids = state.repositories.runningScriptsByWorktreeID[worktree.id] ?? [] ids.insert(definition.id) state.repositories.runningScriptsByWorktreeID[worktree.id] = ids - state.repositories.scriptTintColorByID[definition.id] = definition.resolvedTintColor return .run { _ in await terminalClient.send( .runBlockingScript(worktree, kind: .script(definition), script: definition.command) @@ -754,6 +750,10 @@ struct AppFeature { 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 } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index f1a8d22ff..fafc04508 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -78,7 +78,6 @@ struct RepositoriesFeature { var pendingSetupScriptWorktreeIDs: Set = [] var pendingTerminalFocusWorktreeIDs: Set = [] var runningScriptsByWorktreeID: [Worktree.ID: Set] = [:] - var scriptTintColorByID: [UUID: TerminalTabTintColor] = [:] var archivingWorktreeIDs: Set = [] var deleteScriptWorktreeIDs: Set = [] var deletingWorktreeIDs: Set = [] @@ -1404,12 +1403,6 @@ struct RepositoriesFeature { } else { state.runningScriptsByWorktreeID[worktreeID] = ids } - // Remove the cached tint color when the script ID is no longer - // referenced by any worktree. - let stillReferenced = state.runningScriptsByWorktreeID.values.contains { $0.contains(scriptID) } - if !stillReferenced { - state.scriptTintColorByID.removeValue(forKey: scriptID) - } guard let exitCode, exitCode != 0 else { return .none } state.alert = blockingScriptFailureAlert( kind: kind, @@ -2927,10 +2920,6 @@ struct RepositoriesFeature { let filteredRunningScripts = state.runningScriptsByWorktreeID.filter { availableWorktreeIDs.contains($0.key) } - let remainingScriptIDs = Set(filteredRunningScripts.values.flatMap { $0 }) - let filteredScriptTintColors = state.scriptTintColorByID.filter { - remainingScriptIDs.contains($0.key) - } let filteredArchivingIDs = state.archivingWorktreeIDs let filteredWorktreeInfo = state.worktreeInfoByID.filter { availableWorktreeIDs.contains($0.key) @@ -2945,7 +2934,7 @@ struct RepositoriesFeature { state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs state.runningScriptsByWorktreeID = filteredRunningScripts - state.scriptTintColorByID = filteredScriptTintColors + state.archivingWorktreeIDs = filteredArchivingIDs state.worktreeInfoByID = filteredWorktreeInfo } @@ -2957,7 +2946,6 @@ struct RepositoriesFeature { state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs state.runningScriptsByWorktreeID = filteredRunningScripts - state.scriptTintColorByID = filteredScriptTintColors state.archivingWorktreeIDs = filteredArchivingIDs state.worktreeInfoByID = filteredWorktreeInfo } @@ -3169,9 +3157,13 @@ extension RepositoriesFeature.State { /// Tint colors for scripts currently running in the given worktree, /// ordered deterministically by script ID. - func runningScriptColors(for worktreeID: Worktree.ID) -> [TerminalTabTintColor] { + func runningScriptColors( + for worktreeID: Worktree.ID, + scripts: [ScriptDefinition] + ) -> [TerminalTabTintColor] { guard let scriptIDs = runningScriptsByWorktreeID[worktreeID] else { return [] } - return scriptIDs.sorted().compactMap { scriptTintColorByID[$0] } + let scriptsByID = Dictionary(uniqueKeysWithValues: scripts.map { ($0.id, $0) }) + return scriptIDs.sorted().compactMap { scriptsByID[$0]?.resolvedTintColor } } func pendingWorktree(for id: Worktree.ID?) -> PendingWorktree? { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 57a62d2f6..0ee797b57 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -390,7 +390,7 @@ struct WorktreeDetailView: View { } label: { OpenWorktreeActionMenuLabelView( action: resolvedOpenActionSelection, - shortcutHint: showExtras ? shortcutDisplay(for: AppShortcuts.openFinder, fallback: "") : nil + shortcutHint: showExtras ? resolveShortcutDisplay(for: AppShortcuts.openFinder, fallback: "") : nil ) } .help(openActionHelpText(for: resolvedOpenActionSelection, isDefault: true)) @@ -424,14 +424,9 @@ struct WorktreeDetailView: View { } - 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.openFinder)))" + return "\(action.title) (\(resolveShortcutDisplay(for: AppShortcuts.openFinder)))" } } @@ -612,6 +607,12 @@ 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 + return shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback +} + private struct MultiSelectedWorktreesDetailView: View { let rows: [MultiSelectedWorktreeSummary] @@ -694,7 +695,7 @@ private struct ScriptSplitButton: View { .accessibilityHidden(true) Text("Stop") if commandKeyObserver.isPressed { - Text(shortcutDisplay(for: AppShortcuts.stopRunScript, fallback: "")) + Text(resolveShortcutDisplay(for: AppShortcuts.stopRunScript, fallback: "")) .font(.caption) .foregroundStyle(.secondary) } @@ -711,7 +712,7 @@ private struct ScriptSplitButton: View { .accessibilityHidden(true) Text(primaryScript.displayName) if commandKeyObserver.isPressed { - Text(shortcutDisplay(for: AppShortcuts.runScript, fallback: "")) + Text(resolveShortcutDisplay(for: AppShortcuts.runScript, fallback: "")) .font(.caption) .foregroundStyle(.secondary) } @@ -728,7 +729,7 @@ private struct ScriptSplitButton: View { .accessibilityHidden(true) Text("Run") if commandKeyObserver.isPressed { - Text(shortcutDisplay(for: AppShortcuts.runScript, fallback: "")) + Text(resolveShortcutDisplay(for: AppShortcuts.runScript, fallback: "")) .font(.caption) .foregroundStyle(.secondary) } @@ -784,10 +785,6 @@ private struct ScriptSplitButton: View { } } - private func shortcutDisplay(for shortcut: AppShortcut, fallback: String = "none") -> String { - @Shared(.settingsFile) var settingsFile - return shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback - } } @MainActor diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 96b78506b..80e9496f8 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(\.scripts) private var scripts var body: some View { WorktreeRow( @@ -161,7 +162,7 @@ private struct WorktreeRowContainer: View { hideSubtitle: hideSubtitle, hideSubtitleOnMatch: hideSubtitleOnMatch, showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), - runningScriptColors: store.state.runningScriptColors(for: row.id), + runningScriptColors: store.state.runningScriptColors(for: row.id, scripts: scripts), 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/Terminal/Models/BlockingScriptKind.swift b/supacode/Features/Terminal/Models/BlockingScriptKind.swift index 954e69e19..55b56d8d3 100644 --- a/supacode/Features/Terminal/Models/BlockingScriptKind.swift +++ b/supacode/Features/Terminal/Models/BlockingScriptKind.swift @@ -16,7 +16,7 @@ enum BlockingScriptKind: Sendable { var tabTitle: String { switch self { - case .script(let definition): definition.name + case .script(let definition): definition.displayName case .archive: "Archive Script" case .delete: "Delete Script" } diff --git a/supacodeTests/AppFeatureArchivedSelectionTests.swift b/supacodeTests/AppFeatureArchivedSelectionTests.swift index 4e9d5d9bf..6eb9634a5 100644 --- a/supacodeTests/AppFeatureArchivedSelectionTests.swift +++ b/supacodeTests/AppFeatureArchivedSelectionTests.swift @@ -85,7 +85,6 @@ struct AppFeatureArchivedSelectionTests { activeWorktree.id: [scriptID], archivedWorktree.id: [scriptID], ] - appState.repositories.scriptTintColorByID = [scriptID: .green] let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: appState) { AppFeature() diff --git a/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index 6712cb5e3..7fb4fd2de 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -54,7 +54,7 @@ struct AppFeatureRunScriptTests { await store.send(.runScript) await store.receive(\.runNamedScript) { $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - $0.repositories.scriptTintColorByID = [definition.id: definition.resolvedTintColor] + } await store.finish() @@ -90,7 +90,7 @@ struct AppFeatureRunScriptTests { await store.send(.runNamedScript(definition)) { $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - $0.repositories.scriptTintColorByID = [definition.id: definition.resolvedTintColor] + } await store.finish() } @@ -101,7 +101,7 @@ struct AppFeatureRunScriptTests { let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") var repositoriesState = repositories repositoriesState.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - repositoriesState.scriptTintColorByID = [definition.id: definition.resolvedTintColor] + let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -123,7 +123,7 @@ struct AppFeatureRunScriptTests { ) ) { $0.repositories.runningScriptsByWorktreeID = [:] - $0.repositories.scriptTintColorByID = [:] + } } @@ -213,7 +213,7 @@ struct AppFeatureRunScriptTests { // the settings (e.g. user deleted it while it was executing). var repositoriesState = repositories repositoriesState.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - repositoriesState.scriptTintColorByID = [definition.id: definition.resolvedTintColor] + let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -237,7 +237,7 @@ struct AppFeatureRunScriptTests { ) ) { $0.repositories.runningScriptsByWorktreeID = [:] - $0.repositories.scriptTintColorByID = [:] + } } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 153210bd7..06253c6a4 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1791,7 +1791,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - state.scriptTintColorByID = [definition.id: definition.resolvedTintColor] + let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -1806,7 +1806,7 @@ struct RepositoriesFeatureTests { ) ) { $0.runningScriptsByWorktreeID = [:] - $0.scriptTintColorByID = [:] + $0.alert = expectedScriptFailureAlert( kind: .script(definition), exitMessage: "Script failed (exit code 1).", @@ -1824,7 +1824,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - state.scriptTintColorByID = [definition.id: definition.resolvedTintColor] + let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -1839,7 +1839,7 @@ struct RepositoriesFeatureTests { ) ) { $0.runningScriptsByWorktreeID = [:] - $0.scriptTintColorByID = [:] + } #expect(store.state.alert == nil) } @@ -1851,7 +1851,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - state.scriptTintColorByID = [definition.id: definition.resolvedTintColor] + let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -1866,7 +1866,7 @@ struct RepositoriesFeatureTests { ) ) { $0.runningScriptsByWorktreeID = [:] - $0.scriptTintColorByID = [:] + } #expect(store.state.alert == nil) } @@ -1880,7 +1880,7 @@ struct RepositoriesFeatureTests { let definition = ScriptDefinition(kind: .run, name: "Run", command: "npm start") var state = makeState(repositories: [repository]) state.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - state.scriptTintColorByID = [definition.id: definition.resolvedTintColor] + let store = TestStore(initialState: state) { RepositoriesFeature() } @@ -1897,7 +1897,7 @@ struct RepositoriesFeatureTests { ) ) { $0.runningScriptsByWorktreeID = [:] - $0.scriptTintColorByID = [:] + $0.alert = expectedScriptFailureAlert( kind: .script(definition), exitMessage: "Script failed (exit code 1).", 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 index dcc79fe12..05e547bbe 100644 --- a/supacodeTests/RepositorySettingsScriptTests.swift +++ b/supacodeTests/RepositorySettingsScriptTests.swift @@ -67,6 +67,19 @@ struct RepositorySettingsCodableTests { #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.default + settings.runScript = "stale legacy command" + settings.scripts = [ + ScriptDefinition(kind: .test, command: "npm test") + ] + 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. From 4415cae8cb1d617f7eb4824d8c8ab577fd99a589 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 15:20:01 +0200 Subject: [PATCH 13/20] Fix cross-repo indicator bug, DRY violations, and runScript safety - Fix sidebar indicator disappearing for scripts running in non-selected repositories by falling back to .green when script ID lookup fails - Refactor .binding case to reuse persistAndNotify, removing duplication - Extract scriptButton helper in ScriptSplitButton to DRY three branches - Extract LifecycleScriptSection in RepositoryScriptsSettingsView - Make runScript property private(set) to prevent accidental direct mutation - Clarify stopRunScripts intent with expanded doc comments --- .../Reducer/RepositorySettingsFeature.swift | 10 +- .../Views/RepositoryScriptsSettingsView.swift | 119 +++++++++--------- .../Models/RepositorySettings.swift | 2 +- .../Reducer/RepositoriesFeature.swift | 6 +- .../Views/WorktreeDetailView.swift | 90 +++++++------ .../Terminal/Models/BlockingScriptKind.swift | 4 +- .../Models/WorktreeTerminalState.swift | 6 +- .../AppFeatureDefaultEditorTests.swift | 12 +- .../RepositorySettingsScriptTests.swift | 14 ++- 9 files changed, 133 insertions(+), 130 deletions(-) diff --git a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift index 45a422656..f6af1f8ff 100644 --- a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift @@ -179,15 +179,7 @@ public struct RepositorySettingsFeature { 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 diff --git a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift index e8b609b09..4faebf223 100644 --- a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift +++ b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift @@ -13,68 +13,30 @@ public struct RepositoryScriptsSettingsView: View { public var body: some View { Form { // Lifecycle scripts. - Section { - ScriptCommandEditor(text: $store.settings.setupScript, label: "Setup Script") - } header: { - Label { - VStack(alignment: .leading, spacing: 0) { - Text("Setup Script") - .font(.body) - .bold() - .lineLimit(1) - Text("Runs once after worktree creation.") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } icon: { - Image(systemName: "truck.box.badge.clock").foregroundStyle(.blue).accessibilityHidden(true) - }.labelStyle(.verticallyCentered) - } footer: { - Text("e.g., `pnpm install`") - } - - Section { - ScriptCommandEditor(text: $store.settings.archiveScript, label: "Archive Script") - } header: { - Label { - VStack(alignment: .leading, spacing: 0) { - Text("Archive Script") - .font(.body) - .bold() - .lineLimit(1) - Text("Runs before a worktree is archived.") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } icon: { - Image(systemName: "archivebox").foregroundStyle(.orange).accessibilityHidden(true) - }.labelStyle(.verticallyCentered) - } footer: { - Text("e.g., `docker compose down`") - } - - Section { - ScriptCommandEditor(text: $store.settings.deleteScript, label: "Delete Script") - } header: { - Label { - VStack(alignment: .leading, spacing: 0) { - Text("Delete Script") - .font(.body) - .bold() - .lineLimit(1) - Text("Runs before a worktree is deleted.") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } icon: { - Image(systemName: "trash").foregroundStyle(.red).accessibilityHidden(true) - }.labelStyle(.verticallyCentered) - } footer: { - Text("e.g., `docker compose down`") - } + 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 @@ -131,6 +93,39 @@ public struct RepositoryScriptsSettingsView: View { } } +/// 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 diff --git a/SupacodeSettingsShared/Models/RepositorySettings.swift b/SupacodeSettingsShared/Models/RepositorySettings.swift index 55d0c08aa..6e83e0af0 100644 --- a/SupacodeSettingsShared/Models/RepositorySettings.swift +++ b/SupacodeSettingsShared/Models/RepositorySettings.swift @@ -7,7 +7,7 @@ public nonisolated struct RepositorySettings: Codable, Equatable, Sendable { /// 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 var runScript: String + public private(set) var runScript: String public var scripts: [ScriptDefinition] public var openActionID: String public var worktreeBaseRef: String? diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index fafc04508..6fc952258 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -3156,14 +3156,16 @@ extension RepositoriesFeature.State { } /// Tint colors for scripts currently running in the given worktree, - /// ordered deterministically by script ID. + /// ordered deterministically by script ID. Falls back to `.green` + /// for script IDs not found in the provided array (e.g. when the + /// selected worktree belongs to a different repository). func runningScriptColors( for worktreeID: Worktree.ID, scripts: [ScriptDefinition] ) -> [TerminalTabTintColor] { guard let scriptIDs = runningScriptsByWorktreeID[worktreeID] else { return [] } let scriptsByID = Dictionary(uniqueKeysWithValues: scripts.map { ($0.id, $0) }) - return scriptIDs.sorted().compactMap { scriptsByID[$0]?.resolvedTintColor } + return scriptIDs.sorted().map { scriptsByID[$0]?.resolvedTintColor ?? .green } } func pendingWorktree(for id: Worktree.ID?) -> PendingWorktree? { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 0ee797b57..1753e3172 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -687,57 +687,55 @@ private struct ScriptSplitButton: View { private var primaryButton: some View { let hasRunning = toolbarState.hasRunningRunScript if hasRunning { - Button { - onStopRunScripts() - } label: { - HStack(spacing: 6) { - Image(systemName: "stop.fill") - .accessibilityHidden(true) - Text("Stop") - if commandKeyObserver.isPressed { - Text(resolveShortcutDisplay(for: AppShortcuts.stopRunScript, fallback: "")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .font(.caption) - .help(toolbarState.stopRunScriptHelpText) + scriptButton( + icon: "stop.fill", + label: "Stop", + shortcut: AppShortcuts.stopRunScript, + helpText: toolbarState.stopRunScriptHelpText, + action: onStopRunScripts + ) } else if let primaryScript { - Button { - onRunScript() - } label: { - HStack(spacing: 6) { - Image(systemName: primaryScript.resolvedSystemImage) - .accessibilityHidden(true) - Text(primaryScript.displayName) - if commandKeyObserver.isPressed { - Text(resolveShortcutDisplay(for: AppShortcuts.runScript, fallback: "")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .font(.caption) - .help(toolbarState.runScriptHelpText) + scriptButton( + icon: primaryScript.resolvedSystemImage, + label: primaryScript.displayName, + shortcut: AppShortcuts.runScript, + helpText: toolbarState.runScriptHelpText, + action: onRunScript + ) } else { - Button { - onManageScripts() - } label: { - HStack(spacing: 6) { - Image(systemName: "play.fill") - .accessibilityHidden(true) - Text("Run") - if commandKeyObserver.isPressed { - Text(resolveShortcutDisplay(for: AppShortcuts.runScript, fallback: "")) - .font(.caption) - .foregroundStyle(.secondary) - } + scriptButton( + icon: "play.fill", + label: "Run", + shortcut: AppShortcuts.runScript, + helpText: "Configure scripts in Settings", + action: onManageScripts + ) + } + } + + private func scriptButton( + icon: String, + label: String, + shortcut: AppShortcut, + helpText: String, + action: @escaping () -> Void + ) -> some View { + Button { + action() + } label: { + HStack(spacing: 6) { + Image(systemName: icon) + .accessibilityHidden(true) + Text(label) + if commandKeyObserver.isPressed { + Text(resolveShortcutDisplay(for: shortcut, fallback: "")) + .font(.caption) + .foregroundStyle(.secondary) } } - .font(.caption) - .help("Configure scripts in Settings") } + .font(.caption) + .help(helpText) } @ViewBuilder diff --git a/supacode/Features/Terminal/Models/BlockingScriptKind.swift b/supacode/Features/Terminal/Models/BlockingScriptKind.swift index 55b56d8d3..e88daa65f 100644 --- a/supacode/Features/Terminal/Models/BlockingScriptKind.swift +++ b/supacode/Features/Terminal/Models/BlockingScriptKind.swift @@ -46,7 +46,9 @@ enum BlockingScriptKind: Sendable { } } - /// `true` when this is a `.run`-kind script — the only kind stopped by Cmd+. + /// `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 diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index fd05fb766..7c18cc43a 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -194,7 +194,11 @@ final class WorktreeTerminalState { return true } - /// Stops all running `.run`-kind scripts (backward compat for Cmd+.). + /// 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) diff --git a/supacodeTests/AppFeatureDefaultEditorTests.swift b/supacodeTests/AppFeatureDefaultEditorTests.swift index 855be7d7d..3c64cd9ba 100644 --- a/supacodeTests/AppFeatureDefaultEditorTests.swift +++ b/supacodeTests/AppFeatureDefaultEditorTests.swift @@ -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 diff --git a/supacodeTests/RepositorySettingsScriptTests.swift b/supacodeTests/RepositorySettingsScriptTests.swift index 05e547bbe..e04c728b2 100644 --- a/supacodeTests/RepositorySettingsScriptTests.swift +++ b/supacodeTests/RepositorySettingsScriptTests.swift @@ -70,11 +70,15 @@ struct RepositorySettingsCodableTests { @Test func encodeWithNoRunKindScriptClearsRunScript() throws { // When no `.run`-kind script exists, the encoded `runScript` // should be empty — not the stale legacy value. - var settings = RepositorySettings.default - settings.runScript = "stale legacy command" - settings.scripts = [ - ScriptDefinition(kind: .test, command: "npm test") - ] + 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 == "") From 79dab39d8fd19ff202f0c5eb129795387dfde0f3 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 15:30:29 +0200 Subject: [PATCH 14/20] Optimize script color lookup, add duplicate-run test, fix help text dots - Pre-compute scriptsByID dictionary once in environment instead of rebuilding per sidebar row in runningScriptColors - Add test verifying duplicate runNamedScript is silently rejected - Add trailing dots to help text strings for convention consistency --- supacode/App/ContentView.swift | 13 +++++----- .../Reducer/RepositoriesFeature.swift | 7 +++-- .../Views/WorktreeDetailView.swift | 8 +++--- .../Repositories/Views/WorktreeRowsView.swift | 4 +-- supacodeTests/AppFeatureRunScriptTests.swift | 26 ++++++++++++++++++- 5 files changed, 41 insertions(+), 17 deletions(-) diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 2c6a644dc..3ecd6d55b 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -33,7 +33,7 @@ struct ContentView: View { } .navigationSplitViewStyle(.automatic) .disabled(!store.repositories.isInitialLoadComplete) - .environment(\.scripts, store.scripts) + .environment(\.scriptsByID, Dictionary(uniqueKeysWithValues: store.scripts.map { ($0.id, $0) })) .environment(\.surfaceBackgroundOpacity, terminalManager.surfaceBackgroundOpacity()) .onChange(of: scenePhase) { _, newValue in store.send(.scenePhaseChanged(newValue)) @@ -105,14 +105,15 @@ struct ContentView: View { } -private struct ScriptsEnvironmentKey: EnvironmentKey { - static let defaultValue: [ScriptDefinition] = [] +private struct ScriptsByIDEnvironmentKey: EnvironmentKey { + static let defaultValue: [UUID: ScriptDefinition] = [:] } extension EnvironmentValues { - var scripts: [ScriptDefinition] { - get { self[ScriptsEnvironmentKey.self] } - set { self[ScriptsEnvironmentKey.self] = newValue } + /// Pre-computed lookup for sidebar row color resolution. + var scriptsByID: [UUID: ScriptDefinition] { + get { self[ScriptsByIDEnvironmentKey.self] } + set { self[ScriptsByIDEnvironmentKey.self] = newValue } } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 6fc952258..67e12ff51 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -3157,14 +3157,13 @@ extension RepositoriesFeature.State { /// 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 provided array (e.g. when the - /// selected worktree belongs to a different repository). + /// 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, - scripts: [ScriptDefinition] + scriptsByID: [UUID: ScriptDefinition] ) -> [TerminalTabTintColor] { guard let scriptIDs = runningScriptsByWorktreeID[worktreeID] else { return [] } - let scriptsByID = Dictionary(uniqueKeysWithValues: scripts.map { ($0.id, $0) }) return scriptIDs.sorted().map { scriptsByID[$0]?.resolvedTintColor ?? .green } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 1753e3172..12bb7d7f0 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -707,7 +707,7 @@ private struct ScriptSplitButton: View { icon: "play.fill", label: "Run", shortcut: AppShortcuts.runScript, - helpText: "Configure scripts in Settings", + helpText: "Configure scripts in Settings.", action: onManageScripts ) } @@ -764,13 +764,13 @@ private struct ScriptSplitButton: View { ) } } - .help(isRunning ? "Stop \(script.displayName)" : "Run \(script.displayName)") + .help(isRunning ? "Stop \(script.displayName)." : "Run \(script.displayName).") } Divider() Button("Manage Scripts…") { onManageScripts() } - .help("Open repository settings to manage scripts") + .help("Open repository settings to manage scripts.") } label: { Image(systemName: "chevron.down") .font(.caption2) @@ -779,7 +779,7 @@ private struct ScriptSplitButton: View { .imageScale(.small) .menuIndicator(.hidden) .fixedSize() - .help("Script actions") + .help("Script actions.") } } diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 80e9496f8..932921967 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -153,7 +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(\.scripts) private var scripts + @Environment(\.scriptsByID) private var scriptsByID var body: some View { WorktreeRow( @@ -162,7 +162,7 @@ private struct WorktreeRowContainer: View { hideSubtitle: hideSubtitle, hideSubtitleOnMatch: hideSubtitleOnMatch, showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), - runningScriptColors: store.state.runningScriptColors(for: row.id, scripts: scripts), + 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/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index 7fb4fd2de..881552285 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -90,11 +90,35 @@ struct AppFeatureRunScriptTests { await store.send(.runNamedScript(definition)) { $0.repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] - } 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) From 29d1a7dcc8384628dbed7c869d0f960aa2b5cdf6 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 19:58:32 +0200 Subject: [PATCH 15/20] Add confirmation dialog for script deletion Show an alert before removing a user-defined script in repository settings. Also extract SettingsView subviews to fix type-checker timeout caused by the new @Presents alert state. --- .../Reducer/RepositorySettingsFeature.swift | 28 +++ .../Views/RepositoryScriptsSettingsView.swift | 5 +- .../Settings/Views/SettingsView.swift | 224 +++++++++++------- .../AppFeatureOpenWorktreeTests.swift | 4 +- .../RepositorySettingsScriptTests.swift | 49 +++- 5 files changed, 215 insertions(+), 95 deletions(-) diff --git a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift index f6af1f8ff..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( @@ -65,6 +72,7 @@ public struct RepositorySettingsFeature { case branchDataLoaded([String], defaultBaseRef: String) case addScript(ScriptKind) case removeScript(ScriptDefinition.ID) + case alert(PresentationAction) case delegate(Delegate) case binding(BindingAction) } @@ -171,9 +179,28 @@ public struct RepositorySettingsFeature { 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 @@ -185,6 +212,7 @@ public struct RepositorySettingsFeature { return .none } } + .ifLet(\.$alert, action: \.alert) } /// Persists the current settings and notifies the delegate. diff --git a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift index 4faebf223..cb2a7c3a6 100644 --- a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift +++ b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift @@ -45,9 +45,11 @@ public struct RepositoryScriptsSettingsView: View { TextField("Name", text: $script.name) } ScriptCommandEditor(text: $script.command, label: script.displayName) - Button("Remove Script", role: .destructive) { + Button("Remove Script…", role: .destructive) { store.send(.removeScript(script.id)) } + .buttonStyle(.plain) + .foregroundStyle(.red) .help("Remove this script.") } header: { Label { @@ -62,6 +64,7 @@ public struct RepositoryScriptsSettingsView: View { } } + .alert($store.scope(state: \.alert, action: \.alert)) .formStyle(.grouped) .padding(.top, -20) .padding(.leading, -8) diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 8a412f6cf..6e08821e2 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -38,6 +38,132 @@ 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 + + var body: some View { + RepositoryLabel(name: repository.name, rootURL: repository.rootURL) + .contentShape(Rectangle()) + .accessibilityAddTraits(.isButton) + .onTapGesture { + _ = withAnimation { 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 + DisclosureGroup( + isExpanded: Binding( + get: { expandedRepositories.contains(repository.id) }, + set: { expanded in + if expanded { + expandedRepositories.insert(repository.id) + } else { + expandedRepositories.remove(repository.id) + } + } + ) + ) { + Label("General", systemImage: "gearshape") + .tag(SettingsSection.repository(repository.id)) + Label("Scripts", systemImage: "terminal") + .tag(SettingsSection.repositoryScripts(repository.id)) + } label: { + RepositoryDisclosureLabel( + repository: repository, + settingsStore: settingsStore + ) + } + } + } + } + .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 @@ -59,100 +185,22 @@ struct SettingsView: View { }() 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 - DisclosureGroup( - isExpanded: Binding( - get: { expandedRepositories.contains(repository.id) }, - set: { expanded in - if expanded { - expandedRepositories.insert(repository.id) - } else { - expandedRepositories.remove(repository.id) - } - } - ) - ) { - Label("General", systemImage: "gearshape") - .tag(SettingsSection.repository(repository.id)) - Label("Scripts", systemImage: "terminal") - .tag(SettingsSection.repositoryScripts(repository.id)) - } label: { - RepositoryLabel(name: repository.name, rootURL: repository.rootURL) - } - } - } - } - .listStyle(.sidebar) - .frame(minWidth: 220, maxHeight: .infinity) - .navigationSplitViewColumnWidth(220) - .toolbar(removing: .sidebarToggle) + 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) } } 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") - } - 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") - } - } + SettingsDetailView( + selection: selection, + selectedRepositorySummary: selectedRepositorySummary, + settingsStore: settingsStore, + updatesStore: updatesStore + ) } .toolbar { // Invisible item keeps the toolbar stable when switching between 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/RepositorySettingsScriptTests.swift b/supacodeTests/RepositorySettingsScriptTests.swift index e04c728b2..c592eb257 100644 --- a/supacodeTests/RepositorySettingsScriptTests.swift +++ b/supacodeTests/RepositorySettingsScriptTests.swift @@ -200,7 +200,7 @@ struct RepositorySettingsScriptTests { } } - @Test(.dependencies) func removeScriptRemovesByID() async { + @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") @@ -208,10 +208,51 @@ struct RepositorySettingsScriptTests { store.exhaustivity = .off(showSkippedAssertions: false) await store.send(.removeScript(script2.id)) { - #expect($0.settings.scripts.count == 2) - #expect($0.settings.scripts[0].id == script1.id) - #expect($0.settings.scripts[1].id == script3.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) } } From 4975b206bc9a48a8901d5e65960476163d8c27d5 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 20:00:21 +0200 Subject: [PATCH 16/20] Toggle disclosure on tap when repository is already selected --- .../Settings/Views/SettingsView.swift | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 6e08821e2..5f91336a1 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -42,13 +42,22 @@ private struct RepositoryLabel: View { 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 { - _ = withAnimation { settingsStore.send(.setSelection(.repository(repository.id))) } + guard !isSelected else { + isExpanded.toggle() + return + } + _ = settingsStore.send(.setSelection(.repository(repository.id))) } } } @@ -96,7 +105,17 @@ private struct SettingsSidebarView: View { } label: { RepositoryDisclosureLabel( repository: repository, - settingsStore: settingsStore + settingsStore: settingsStore, + isExpanded: Binding( + get: { expandedRepositories.contains(repository.id) }, + set: { expanded in + if expanded { + expandedRepositories.insert(repository.id) + } else { + expandedRepositories.remove(repository.id) + } + } + ) ) } } From 4cd7fb8248f3f9291bc1e295f51ab21c9cf30b9c Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 20:08:03 +0200 Subject: [PATCH 17/20] Replace split buttons with Menu primaryAction for open and script toolbars --- .../Views/WorktreeDetailView.swift | 217 +++++++----------- 1 file changed, 85 insertions(+), 132 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 0be1a5024..3558b9d17 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -367,7 +367,7 @@ struct WorktreeDetailView: View { ToolbarSpacer(.flexible) - ToolbarItemGroup { + ToolbarItem { openMenu( openActionSelection: toolbarState.openActionSelection, showExtras: toolbarState.showExtras @@ -376,7 +376,7 @@ struct WorktreeDetailView: View { ToolbarSpacer(.fixed) ToolbarItem { - ScriptSplitButton( + ScriptMenu( toolbarState: toolbarState, onRunScript: onRunScript, onRunNamedScript: onRunNamedScript, @@ -394,45 +394,36 @@ 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 ? resolveShortcutDisplay(for: AppShortcuts.openWorktree, fallback: "") : nil ) + } primaryAction: { + onOpenWorktree(primarySelection) } + .menuIndicator(.hidden) .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 (\(resolveShortcutDisplay(for: AppShortcuts.revealInFinder)))") - } label: { - Image(systemName: "chevron.down") - .font(.caption2) - .accessibilityLabel("Open in menu") - } - .imageScale(.small) - .menuIndicator(.hidden) - .fixedSize() - .help("Open in…") } private func openActionHelpText(for action: OpenWorktreeAction, isDefault: Bool) -> String { @@ -671,10 +662,9 @@ private struct MultiSelectedWorktreesDetailView: View { } } -/// Split-button for running scripts in the toolbar. -/// Primary button runs the selected/default script. -/// Chevron dropdown lists all configured scripts with run/stop options. -private struct ScriptSplitButton: View { +/// Menu with primary action for running scripts in the toolbar. +/// Click runs the default script; long-press/arrow opens the full script list. +private struct ScriptMenu: View { let toolbarState: WorktreeDetailView.WorktreeToolbarState let onRunScript: () -> Void let onRunNamedScript: (ScriptDefinition) -> Void @@ -688,112 +678,75 @@ private struct ScriptSplitButton: View { } var body: some View { - HStack(spacing: 0) { - primaryButton - scriptMenu - } - } - - @ViewBuilder - private var primaryButton: some View { let hasRunning = toolbarState.hasRunningRunScript - if hasRunning { - scriptButton( - icon: "stop.fill", - label: "Stop", - shortcut: AppShortcuts.stopRunScript, - helpText: toolbarState.stopRunScriptHelpText, - action: onStopRunScripts - ) - } else if let primaryScript { - scriptButton( - icon: primaryScript.resolvedSystemImage, - label: primaryScript.displayName, - shortcut: AppShortcuts.runScript, - helpText: toolbarState.runScriptHelpText, - action: onRunScript - ) - } else { - scriptButton( - icon: "play.fill", - label: "Run", - shortcut: AppShortcuts.runScript, - helpText: "Configure scripts in Settings.", - action: onManageScripts - ) - } - } - - private func scriptButton( - icon: String, - label: String, - shortcut: AppShortcut, - helpText: String, - action: @escaping () -> Void - ) -> some View { - Button { - action() - } label: { - HStack(spacing: 6) { - Image(systemName: icon) - .accessibilityHidden(true) - Text(label) - if commandKeyObserver.isPressed { - Text(resolveShortcutDisplay(for: shortcut, fallback: "")) - .font(.caption) - .foregroundStyle(.secondary) + 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.fill" : script.resolvedSystemImage, + color: script.resolvedTintColor.nsColor, + ) + } } + .help(isRunning ? "Stop \(script.displayName)." : "Run \(script.displayName).") + } + 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(helpText) + .menuIndicator(.hidden) + .help(primaryHelpText(hasRunning: hasRunning)) } @ViewBuilder - private var scriptMenu: some View { - // Show the dropdown unless the only script is the primary .run script. - let showMenu = - toolbarState.scripts.count > 1 - || (toolbarState.scripts.count == 1 && toolbarState.scripts.first?.kind != .run) - if showMenu { - 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.fill" : script.resolvedSystemImage, - color: script.resolvedTintColor.nsColor, - ) - } - } - .help(isRunning ? "Stop \(script.displayName)." : "Run \(script.displayName).") - } - Divider() - Button("Manage Scripts…") { - onManageScripts() - } - .help("Open repository settings to manage scripts.") - } label: { - Image(systemName: "chevron.down") - .font(.caption2) - .accessibilityLabel("Script menu") + private func scriptLabel(hasRunning: Bool) -> some View { + let icon = hasRunning ? "stop.fill" : (primaryScript?.resolvedSystemImage ?? "play.fill") + let label = hasRunning ? "Stop" : (primaryScript?.displayName ?? "Run") + let shortcut = hasRunning ? AppShortcuts.stopRunScript : AppShortcuts.runScript + HStack(spacing: 6) { + Image(systemName: icon) + .accessibilityHidden(true) + Text(label) + if commandKeyObserver.isPressed { + Text(resolveShortcutDisplay(for: shortcut, fallback: "")) + .font(.caption) + .foregroundStyle(.secondary) } - .imageScale(.small) - .menuIndicator(.hidden) - .fixedSize() - .help("Script actions.") } + .font(.caption) } + private func primaryHelpText(hasRunning: Bool) -> String { + if hasRunning { + return toolbarState.stopRunScriptHelpText + } + guard primaryScript != nil else { + return "Configure scripts in Settings." + } + return toolbarState.runScriptHelpText + } } @MainActor From a8716fd26f55ce3affec27605d18c82c8ed1a9c7 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 20:19:05 +0200 Subject: [PATCH 18/20] Show menu indicator on open and script toolbar menus --- .../OpenWorktreeActionMenuLabelView.swift | 22 +++++++------------ .../Views/WorktreeDetailView.swift | 21 ++++++++---------- 2 files changed, 17 insertions(+), 26 deletions(-) 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 3558b9d17..9610b520e 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -421,7 +421,6 @@ struct WorktreeDetailView: View { } primaryAction: { onOpenWorktree(primarySelection) } - .menuIndicator(.hidden) .help(openActionHelpText(for: primarySelection, isDefault: true)) } } @@ -716,26 +715,24 @@ private struct ScriptMenu: View { onManageScripts() } } - .menuIndicator(.hidden) .help(primaryHelpText(hasRunning: hasRunning)) } @ViewBuilder private func scriptLabel(hasRunning: Bool) -> some View { - let icon = hasRunning ? "stop.fill" : (primaryScript?.resolvedSystemImage ?? "play.fill") + let icon = hasRunning ? "stop" : (primaryScript?.resolvedSystemImage ?? "play") let label = hasRunning ? "Stop" : (primaryScript?.displayName ?? "Run") let shortcut = hasRunning ? AppShortcuts.stopRunScript : AppShortcuts.runScript - HStack(spacing: 6) { + Label { + Text( + commandKeyObserver.isPressed + ? resolveShortcutDisplay(for: shortcut, fallback: "") + : label + ) + } icon: { Image(systemName: icon) .accessibilityHidden(true) - Text(label) - if commandKeyObserver.isPressed { - Text(resolveShortcutDisplay(for: shortcut, fallback: "")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .font(.caption) + }.labelStyle(.titleAndIcon) } private func primaryHelpText(hasRunning: Bool) -> String { From b9871cc58320421be3702e770d1ad38b6f70a342 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 21:10:31 +0200 Subject: [PATCH 19/20] Populate supacode repo-specific scripts --- supacode.json | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) 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 From b8bc52ef10ca0f00b850ce34934bbe1cb2ca3622 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Apr 2026 21:35:04 +0200 Subject: [PATCH 20/20] Address review findings from argue-review debate - Extract duplicate isExpanded Binding to local let in SettingsView - Update toolbar placeholder to match live ScriptMenu (play instead of play.fill, Label instead of HStack) - Guard against empty shortcut display in resolveShortcutDisplay - Use fallback label in ScriptMenu when shortcut resolves to empty - Use stroke-only stop icon consistently (stop instead of stop.fill) - Make Divider conditional on non-empty scripts in ScriptMenu dropdown - Fix doc comments for ScriptMenu and primaryScript --- .../Views/WorktreeDetailView.swift | 23 +++++++------ .../Settings/Views/SettingsView.swift | 32 +++++++------------ 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 9610b520e..ed11e5677 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -298,7 +298,7 @@ struct WorktreeDetailView: View { let scripts: [ScriptDefinition] let runningScriptIDs: Set - /// The script the primary toolbar button should run (always the `.run` script). + /// The first `.run`-kind script, if any. var primaryScript: ScriptDefinition? { scripts.primaryScript } @@ -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) } @@ -611,7 +612,8 @@ private struct MultiSelectedWorktreeSummary: Identifiable { /// 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 - return shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback + let display = shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback + return display.isEmpty ? fallback : display } private struct MultiSelectedWorktreesDetailView: View { @@ -662,7 +664,8 @@ private struct MultiSelectedWorktreesDetailView: View { } /// Menu with primary action for running scripts in the toolbar. -/// Click runs the default script; long-press/arrow opens the full script list. +/// 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 @@ -692,14 +695,16 @@ private struct ScriptMenu: View { Text(isRunning ? "Stop \(script.displayName)" : script.displayName) } icon: { Image.tintedSymbol( - isRunning ? "stop.fill" : script.resolvedSystemImage, + isRunning ? "stop" : script.resolvedSystemImage, color: script.resolvedTintColor.nsColor, ) } } .help(isRunning ? "Stop \(script.displayName)." : "Run \(script.displayName).") } - Divider() + if !toolbarState.scripts.isEmpty { + Divider() + } Button("Manage Scripts…") { onManageScripts() } @@ -726,7 +731,7 @@ private struct ScriptMenu: View { Label { Text( commandKeyObserver.isPressed - ? resolveShortcutDisplay(for: shortcut, fallback: "") + ? resolveShortcutDisplay(for: shortcut, fallback: label) : label ) } icon: { diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 5f91336a1..2f1f164b4 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -86,18 +86,17 @@ private struct SettingsSidebarView: View { Section("Repositories") { ForEach(settingsStore.repositorySummaries, id: \.id) { repository in - DisclosureGroup( - isExpanded: Binding( - get: { expandedRepositories.contains(repository.id) }, - set: { expanded in - if expanded { - expandedRepositories.insert(repository.id) - } else { - expandedRepositories.remove(repository.id) - } + 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") @@ -106,16 +105,7 @@ private struct SettingsSidebarView: View { RepositoryDisclosureLabel( repository: repository, settingsStore: settingsStore, - isExpanded: Binding( - get: { expandedRepositories.contains(repository.id) }, - set: { expanded in - if expanded { - expandedRepositories.insert(repository.id) - } else { - expandedRepositories.remove(repository.id) - } - } - ) + isExpanded: isExpanded ) } }