Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
464e5a6
Add ScriptKind, ScriptDefinition, and refactor BlockingScriptKind
sbertix Apr 14, 2026
3a59271
Add keyboard shortcuts for test, debug, and deploy scripts
sbertix Apr 14, 2026
7da5c28
Add scripts settings table and expandable repo sidebar
sbertix Apr 14, 2026
13a8e22
Add split-button toolbar and command palette for scripts
sbertix Apr 14, 2026
4ee3c66
Add color-cycling sidebar indicator for running scripts
sbertix Apr 14, 2026
b13583a
Fix review findings: encode compat, decode resilience, and cleanup
sbertix Apr 14, 2026
c173752
Add lint/format script types, lock kind at creation, displayName
sbertix Apr 14, 2026
d4dd228
Restructure scripts settings, toolbar labels, and cleanup
sbertix Apr 15, 2026
1dc6b7d
Fix decode resilience, dedup, tests, and cleanup
sbertix Apr 15, 2026
c52fe57
Fix opening brace lint violations in persistence extensions
sbertix Apr 15, 2026
6a8d392
Fix identity, kind defaults, and dead code cleanup
sbertix Apr 15, 2026
f193e55
Remove tint color cache, fix displayName, encode fallback, DRY
sbertix Apr 15, 2026
4415cae
Fix cross-repo indicator bug, DRY violations, and runScript safety
sbertix Apr 15, 2026
79dab39
Optimize script color lookup, add duplicate-run test, fix help text dots
sbertix Apr 15, 2026
a6caec6
Merge origin/main into sbertix/multiple-scripts
sbertix Apr 15, 2026
29d1a7d
Add confirmation dialog for script deletion
sbertix Apr 15, 2026
4975b20
Toggle disclosure on tap when repository is already selected
sbertix Apr 15, 2026
4cd7fb8
Replace split buttons with Menu primaryAction for open and script too…
sbertix Apr 15, 2026
a8716fd
Show menu indicator on open and script toolbar menus
sbertix Apr 15, 2026
b9871cc
Populate supacode repo-specific scripts
sbertix Apr 15, 2026
b8bc52e
Address review findings from argue-review debate
sbertix Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 56 additions & 9 deletions SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public struct RepositorySettingsFeature {
)
}

@Presents public var alert: AlertState<Alert>?

public init(
rootURL: URL,
settings: RepositorySettings,
Expand All @@ -52,6 +54,11 @@ public struct RepositorySettingsFeature {
}
}

@CasePathable
public enum Alert: Equatable {
case confirmRemoveScript(ScriptDefinition.ID)
}

public enum Action: BindableAction {
case task
case settingsLoaded(
Expand All @@ -63,6 +70,9 @@ public struct RepositorySettingsFeature {
globalPullRequestMergeStrategy: PullRequestMergeStrategy
)
case branchDataLoaded([String], defaultBaseRef: String)
case addScript(ScriptKind)
case removeScript(ScriptDefinition.ID)
case alert(PresentationAction<Alert>)
case delegate(Delegate)
case binding(BindingAction<State>)
}
Expand Down Expand Up @@ -160,24 +170,61 @@ public struct RepositorySettingsFeature {
state.isBranchDataLoaded = true
return .none

case .addScript(let kind):
// Predefined kinds are unique; reject duplicates.
guard kind == .custom || !state.settings.scripts.contains(where: { $0.kind == kind }) else {
return .none
}
state.settings.scripts.append(ScriptDefinition(kind: kind))
return persistAndNotify(state: &state)

case .removeScript(let id):
guard let script = state.settings.scripts.first(where: { $0.id == id }) else { return .none }
state.alert = AlertState {
TextState("Remove \"\(script.displayName)\" script?")
} actions: {
ButtonState(role: .destructive, action: .confirmRemoveScript(id)) {
TextState("Remove")
}
ButtonState(role: .cancel) {
TextState("Cancel")
}
} message: {
TextState("This action cannot be undone.")
}
return .none

case .alert(.presented(.confirmRemoveScript(let id))):
state.settings.scripts.removeAll { $0.id == id }
return persistAndNotify(state: &state)

case .alert:
return .none

case .binding:
if state.isBareRepository {
state.settings.copyIgnoredOnWorktreeCreate = nil
state.settings.copyUntrackedOnWorktreeCreate = nil
}
let rootURL = state.rootURL
var normalizedSettings = state.settings
normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(
normalizedSettings.worktreeBaseDirectoryPath,
repositoryRootURL: rootURL
)
@Shared(.repositorySettings(rootURL)) var repositorySettings
$repositorySettings.withLock { $0 = normalizedSettings }
return .send(.delegate(.settingsChanged(rootURL)))
return persistAndNotify(state: &state)

case .delegate:
return .none
}
}
.ifLet(\.$alert, action: \.alert)
}

/// Persists the current settings and notifies the delegate.
private func persistAndNotify(state: inout State) -> Effect<Action> {
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)))
}
}
2 changes: 1 addition & 1 deletion SupacodeSettingsFeature/Reducer/SettingsFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ public struct SettingsFeature {
state.repositorySettings = nil
return
}
guard case .repository(let repositoryID) = selection else {
guard let repositoryID = selection.repositoryID else {
state.repositorySettings = nil
return
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import SwiftUI
import SupacodeSettingsShared
import SwiftUI

struct AppearanceOptionCardView: View {
let mode: AppearanceMode
Expand Down
145 changes: 145 additions & 0 deletions SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import ComposableArchitecture
import SupacodeSettingsShared
import SwiftUI

/// Settings sub-section for managing on-demand and lifecycle scripts.
public struct RepositoryScriptsSettingsView: View {
@Bindable var store: StoreOf<RepositorySettingsFeature>

public init(store: StoreOf<RepositorySettingsFeature>) {
self.store = store
}

public var body: some View {
Form {
// Lifecycle scripts.
LifecycleScriptSection(
text: $store.settings.setupScript,
title: "Setup Script",
subtitle: "Runs once after worktree creation.",
icon: "truck.box.badge.clock",
iconColor: .blue,
footerExample: "pnpm install"
)
LifecycleScriptSection(
text: $store.settings.archiveScript,
title: "Archive Script",
subtitle: "Runs before a worktree is archived.",
icon: "archivebox",
iconColor: .orange,
footerExample: "docker compose down"
)
LifecycleScriptSection(
text: $store.settings.deleteScript,
title: "Delete Script",
subtitle: "Runs before a worktree is deleted.",
icon: "trash",
iconColor: .red,
footerExample: "docker compose down"
)

// User-defined scripts, each in its own section.
ForEach($store.settings.scripts) { $script in
Section {
if script.kind == .custom {
TextField("Name", text: $script.name)
}
ScriptCommandEditor(text: $script.command, label: script.displayName)
Button("Remove Script…", role: .destructive) {
store.send(.removeScript(script.id))
}
.buttonStyle(.plain)
.foregroundStyle(.red)
.help("Remove this script.")
} header: {
Label {
Text("\(script.displayName) Script")
.font(.body)
.bold()
} icon: {
Image(systemName: script.resolvedSystemImage).foregroundStyle(script.resolvedTintColor.color)
.accessibilityHidden(true)
}.labelStyle(.verticallyCentered)
}
}

}
.alert($store.scope(state: \.alert, action: \.alert))
.formStyle(.grouped)
.padding(.top, -20)
.padding(.leading, -8)
.padding(.trailing, -6)
.toolbar {
ToolbarItem(placement: .primaryAction) {
let usedKinds = Set(store.settings.scripts.map(\.kind))
Menu {
ForEach(ScriptKind.allCases, id: \.self) { kind in
if kind == .custom || !usedKinds.contains(kind) {
Button {
store.send(.addScript(kind))
} label: {
Label {
Text("\(kind.defaultName) Script")
} icon: {
Image.tintedSymbol(kind.defaultSystemImage, color: kind.defaultTintColor.nsColor)
}
}
}
}
} label: {
Image(systemName: "plus")
.accessibilityLabel("Add Script")
}
.help("Add a new script.")
}
}
}
}

/// Reusable section for lifecycle scripts (setup, archive, delete).
private struct LifecycleScriptSection: View {
@Binding var text: String
let title: String
let subtitle: String
let icon: String
let iconColor: Color
let footerExample: String

var body: some View {
Section {
ScriptCommandEditor(text: $text, label: title)
} header: {
Label {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(.body)
.bold()
.lineLimit(1)
Text(subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
} icon: {
Image(systemName: icon).foregroundStyle(iconColor).accessibilityHidden(true)
}.labelStyle(.verticallyCentered)
} footer: {
Text("e.g., `\(footerExample)`")
}
}
}

/// Monospaced text editor for script commands.
private struct ScriptCommandEditor: View {
@Binding var text: String
let label: String

var body: some View {
TextEditor(text: $text)
.monospaced()
.textEditorStyle(.plain)
.autocorrectionDisabled()
.frame(height: 90)
.accessibilityLabel(label)
}
}
49 changes: 0 additions & 49 deletions SupacodeSettingsFeature/Views/RepositorySettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,30 +108,6 @@ public struct RepositorySettingsView: View {
description: "Path to the repository root."
)
}
ScriptSection(
title: "Setup Script",
subtitle: "Runs once after worktree creation.",
text: settings.setupScript,
placeholder: "claude --dangerously-skip-permissions"
)
ScriptSection(
title: "Run Script",
subtitle: "Launched on demand from the toolbar.",
text: settings.runScript,
placeholder: "npm run dev"
)
ScriptSection(
title: "Archive Script",
subtitle: "Runs before a worktree is archived.",
text: settings.archiveScript,
placeholder: "docker compose down"
)
ScriptSection(
title: "Delete Script",
subtitle: "Runs before a worktree is deleted.",
text: settings.deleteScript,
placeholder: "docker compose down"
)
}
.formStyle(.grouped)
.padding(.top, -20)
Expand All @@ -143,31 +119,6 @@ public struct RepositorySettingsView: View {
}
}

// MARK: - Script section.

private struct ScriptSection: View {
let title: String
let subtitle: String
let text: Binding<String>
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 {
Expand Down
11 changes: 11 additions & 0 deletions SupacodeSettingsFeature/Views/SettingsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Loading
Loading