Skip to content

Commit c892710

Browse files
authored
Merge b8bc52e into bfcd2f0
2 parents bfcd2f0 + b8bc52e commit c892710

66 files changed

Lines changed: 2033 additions & 663 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.swiftlint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ included:
44
- supacode
55
- supacode-cli
66
- supacodeTests
7+
- SupacodeSettingsShared
8+
- SupacodeSettingsFeature
79
excluded:
810
- ThirdParty/ghostty
911
# Skill content contains long markdown lines inside multiline strings.
1012
- supacode/Features/Settings/BusinessLogic/CLISkillContent.swift
13+
- SupacodeSettingsShared/BusinessLogic/CLISkillContent.swift
1114

1215
disabled_rules:
1316
- file_length

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ test: $(TUIST_DEVELOPMENT_GENERATION_STAMP) # Run all tests
117117
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; \
118118
fi
119119

120-
format: # Format code with swift format (local only)
121-
swift format -p --in-place --recursive --configuration ./.swift-format.json supacode supacode-cli supacodeTests
120+
format: # Format code with swift format (local only).
121+
swift format -p --in-place --recursive --configuration ./.swift-format.json supacode supacode-cli supacodeTests SupacodeSettingsShared SupacodeSettingsFeature
122122

123123
lint: # Lint code with swiftlint
124124
mise exec -- swiftlint lint --quiet --config .swiftlint.yml

SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ public struct RepositorySettingsFeature {
2727
)
2828
}
2929

30+
@Presents public var alert: AlertState<Alert>?
31+
3032
public init(
3133
rootURL: URL,
3234
settings: RepositorySettings,
@@ -52,6 +54,11 @@ public struct RepositorySettingsFeature {
5254
}
5355
}
5456

57+
@CasePathable
58+
public enum Alert: Equatable {
59+
case confirmRemoveScript(ScriptDefinition.ID)
60+
}
61+
5562
public enum Action: BindableAction {
5663
case task
5764
case settingsLoaded(
@@ -63,6 +70,9 @@ public struct RepositorySettingsFeature {
6370
globalPullRequestMergeStrategy: PullRequestMergeStrategy
6471
)
6572
case branchDataLoaded([String], defaultBaseRef: String)
73+
case addScript(ScriptKind)
74+
case removeScript(ScriptDefinition.ID)
75+
case alert(PresentationAction<Alert>)
6676
case delegate(Delegate)
6777
case binding(BindingAction<State>)
6878
}
@@ -160,24 +170,61 @@ public struct RepositorySettingsFeature {
160170
state.isBranchDataLoaded = true
161171
return .none
162172

173+
case .addScript(let kind):
174+
// Predefined kinds are unique; reject duplicates.
175+
guard kind == .custom || !state.settings.scripts.contains(where: { $0.kind == kind }) else {
176+
return .none
177+
}
178+
state.settings.scripts.append(ScriptDefinition(kind: kind))
179+
return persistAndNotify(state: &state)
180+
181+
case .removeScript(let id):
182+
guard let script = state.settings.scripts.first(where: { $0.id == id }) else { return .none }
183+
state.alert = AlertState {
184+
TextState("Remove \"\(script.displayName)\" script?")
185+
} actions: {
186+
ButtonState(role: .destructive, action: .confirmRemoveScript(id)) {
187+
TextState("Remove")
188+
}
189+
ButtonState(role: .cancel) {
190+
TextState("Cancel")
191+
}
192+
} message: {
193+
TextState("This action cannot be undone.")
194+
}
195+
return .none
196+
197+
case .alert(.presented(.confirmRemoveScript(let id))):
198+
state.settings.scripts.removeAll { $0.id == id }
199+
return persistAndNotify(state: &state)
200+
201+
case .alert:
202+
return .none
203+
163204
case .binding:
164205
if state.isBareRepository {
165206
state.settings.copyIgnoredOnWorktreeCreate = nil
166207
state.settings.copyUntrackedOnWorktreeCreate = nil
167208
}
168-
let rootURL = state.rootURL
169-
var normalizedSettings = state.settings
170-
normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(
171-
normalizedSettings.worktreeBaseDirectoryPath,
172-
repositoryRootURL: rootURL
173-
)
174-
@Shared(.repositorySettings(rootURL)) var repositorySettings
175-
$repositorySettings.withLock { $0 = normalizedSettings }
176-
return .send(.delegate(.settingsChanged(rootURL)))
209+
return persistAndNotify(state: &state)
177210

178211
case .delegate:
179212
return .none
180213
}
181214
}
215+
.ifLet(\.$alert, action: \.alert)
216+
}
217+
218+
/// Persists the current settings and notifies the delegate.
219+
private func persistAndNotify(state: inout State) -> Effect<Action> {
220+
let rootURL = state.rootURL
221+
var normalizedSettings = state.settings
222+
normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(
223+
normalizedSettings.worktreeBaseDirectoryPath,
224+
repositoryRootURL: rootURL
225+
)
226+
@Shared(.repositorySettings(rootURL)) var repositorySettings
227+
$repositorySettings.withLock { $0 = normalizedSettings }
228+
return .send(.delegate(.settingsChanged(rootURL)))
182229
}
183230
}

SupacodeSettingsFeature/Reducer/SettingsFeature.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ public struct SettingsFeature {
546546
state.repositorySettings = nil
547547
return
548548
}
549-
guard case .repository(let repositoryID) = selection else {
549+
guard let repositoryID = selection.repositoryID else {
550550
state.repositorySettings = nil
551551
return
552552
}

SupacodeSettingsFeature/Views/AppearanceOptionCardView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import SwiftUI
21
import SupacodeSettingsShared
2+
import SwiftUI
33

44
struct AppearanceOptionCardView: View {
55
let mode: AppearanceMode
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import ComposableArchitecture
2+
import SupacodeSettingsShared
3+
import SwiftUI
4+
5+
/// Settings sub-section for managing on-demand and lifecycle scripts.
6+
public struct RepositoryScriptsSettingsView: View {
7+
@Bindable var store: StoreOf<RepositorySettingsFeature>
8+
9+
public init(store: StoreOf<RepositorySettingsFeature>) {
10+
self.store = store
11+
}
12+
13+
public var body: some View {
14+
Form {
15+
// Lifecycle scripts.
16+
LifecycleScriptSection(
17+
text: $store.settings.setupScript,
18+
title: "Setup Script",
19+
subtitle: "Runs once after worktree creation.",
20+
icon: "truck.box.badge.clock",
21+
iconColor: .blue,
22+
footerExample: "pnpm install"
23+
)
24+
LifecycleScriptSection(
25+
text: $store.settings.archiveScript,
26+
title: "Archive Script",
27+
subtitle: "Runs before a worktree is archived.",
28+
icon: "archivebox",
29+
iconColor: .orange,
30+
footerExample: "docker compose down"
31+
)
32+
LifecycleScriptSection(
33+
text: $store.settings.deleteScript,
34+
title: "Delete Script",
35+
subtitle: "Runs before a worktree is deleted.",
36+
icon: "trash",
37+
iconColor: .red,
38+
footerExample: "docker compose down"
39+
)
40+
41+
// User-defined scripts, each in its own section.
42+
ForEach($store.settings.scripts) { $script in
43+
Section {
44+
if script.kind == .custom {
45+
TextField("Name", text: $script.name)
46+
}
47+
ScriptCommandEditor(text: $script.command, label: script.displayName)
48+
Button("Remove Script…", role: .destructive) {
49+
store.send(.removeScript(script.id))
50+
}
51+
.buttonStyle(.plain)
52+
.foregroundStyle(.red)
53+
.help("Remove this script.")
54+
} header: {
55+
Label {
56+
Text("\(script.displayName) Script")
57+
.font(.body)
58+
.bold()
59+
} icon: {
60+
Image(systemName: script.resolvedSystemImage).foregroundStyle(script.resolvedTintColor.color)
61+
.accessibilityHidden(true)
62+
}.labelStyle(.verticallyCentered)
63+
}
64+
}
65+
66+
}
67+
.alert($store.scope(state: \.alert, action: \.alert))
68+
.formStyle(.grouped)
69+
.padding(.top, -20)
70+
.padding(.leading, -8)
71+
.padding(.trailing, -6)
72+
.toolbar {
73+
ToolbarItem(placement: .primaryAction) {
74+
let usedKinds = Set(store.settings.scripts.map(\.kind))
75+
Menu {
76+
ForEach(ScriptKind.allCases, id: \.self) { kind in
77+
if kind == .custom || !usedKinds.contains(kind) {
78+
Button {
79+
store.send(.addScript(kind))
80+
} label: {
81+
Label {
82+
Text("\(kind.defaultName) Script")
83+
} icon: {
84+
Image.tintedSymbol(kind.defaultSystemImage, color: kind.defaultTintColor.nsColor)
85+
}
86+
}
87+
}
88+
}
89+
} label: {
90+
Image(systemName: "plus")
91+
.accessibilityLabel("Add Script")
92+
}
93+
.help("Add a new script.")
94+
}
95+
}
96+
}
97+
}
98+
99+
/// Reusable section for lifecycle scripts (setup, archive, delete).
100+
private struct LifecycleScriptSection: View {
101+
@Binding var text: String
102+
let title: String
103+
let subtitle: String
104+
let icon: String
105+
let iconColor: Color
106+
let footerExample: String
107+
108+
var body: some View {
109+
Section {
110+
ScriptCommandEditor(text: $text, label: title)
111+
} header: {
112+
Label {
113+
VStack(alignment: .leading, spacing: 0) {
114+
Text(title)
115+
.font(.body)
116+
.bold()
117+
.lineLimit(1)
118+
Text(subtitle)
119+
.font(.footnote)
120+
.foregroundStyle(.secondary)
121+
.lineLimit(1)
122+
}
123+
} icon: {
124+
Image(systemName: icon).foregroundStyle(iconColor).accessibilityHidden(true)
125+
}.labelStyle(.verticallyCentered)
126+
} footer: {
127+
Text("e.g., `\(footerExample)`")
128+
}
129+
}
130+
}
131+
132+
/// Monospaced text editor for script commands.
133+
private struct ScriptCommandEditor: View {
134+
@Binding var text: String
135+
let label: String
136+
137+
var body: some View {
138+
TextEditor(text: $text)
139+
.monospaced()
140+
.textEditorStyle(.plain)
141+
.autocorrectionDisabled()
142+
.frame(height: 90)
143+
.accessibilityLabel(label)
144+
}
145+
}

SupacodeSettingsFeature/Views/RepositorySettingsView.swift

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -108,30 +108,6 @@ public struct RepositorySettingsView: View {
108108
description: "Path to the repository root."
109109
)
110110
}
111-
ScriptSection(
112-
title: "Setup Script",
113-
subtitle: "Runs once after worktree creation.",
114-
text: settings.setupScript,
115-
placeholder: "claude --dangerously-skip-permissions"
116-
)
117-
ScriptSection(
118-
title: "Run Script",
119-
subtitle: "Launched on demand from the toolbar.",
120-
text: settings.runScript,
121-
placeholder: "npm run dev"
122-
)
123-
ScriptSection(
124-
title: "Archive Script",
125-
subtitle: "Runs before a worktree is archived.",
126-
text: settings.archiveScript,
127-
placeholder: "docker compose down"
128-
)
129-
ScriptSection(
130-
title: "Delete Script",
131-
subtitle: "Runs before a worktree is deleted.",
132-
text: settings.deleteScript,
133-
placeholder: "docker compose down"
134-
)
135111
}
136112
.formStyle(.grouped)
137113
.padding(.top, -20)
@@ -143,31 +119,6 @@ public struct RepositorySettingsView: View {
143119
}
144120
}
145121

146-
// MARK: - Script section.
147-
148-
private struct ScriptSection: View {
149-
let title: String
150-
let subtitle: String
151-
let text: Binding<String>
152-
let placeholder: String
153-
154-
var body: some View {
155-
Section {
156-
TextEditor(text: text)
157-
.monospaced()
158-
.textEditorStyle(.plain)
159-
.autocorrectionDisabled()
160-
.frame(height: 112)
161-
.accessibilityLabel(title)
162-
} header: {
163-
Text(title)
164-
Text(subtitle)
165-
} footer: {
166-
Text("e.g., `\(placeholder)`")
167-
}
168-
}
169-
}
170-
171122
// MARK: - Environment row.
172123

173124
private struct ScriptEnvironmentRow: View {

SupacodeSettingsFeature/Views/SettingsSection.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,15 @@ public enum SettingsSection: Hashable {
99
case updates
1010
case github
1111
case repository(String)
12+
case repositoryScripts(String)
13+
14+
/// The repository ID for repository-scoped sections.
15+
public var repositoryID: String? {
16+
switch self {
17+
case .repository(let id), .repositoryScripts(let id):
18+
id
19+
default:
20+
nil
21+
}
22+
}
1223
}

0 commit comments

Comments
 (0)