gitbay/Views/Repos/RepoSettingsView.swift
274 lines · 10278 bytes
1import SwiftUI
2
3/// Admin knobs, one command per control. A non-admin sees the server's
4/// refusal instead of a half-working form.
5struct RepoSettingsView: View {
6
7 @State private var model: RepoSettingsViewModel
8 @State private var descriptionText = ""
9 @State private var websiteText = ""
10 @State private var newTopic = ""
11 @State private var newBranch = ""
12 @State private var loadedOnce = false
13
14 init(client: GitbayClient, repo: String) {
15 _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo))
16 }
17
18 var body: some View {
19 List {
20 if let loaded = model.state.value {
21 if let error = model.actionError {
22 Section {
23 GBNotice(error, .gbWarn)
24 }
25 }
26 aboutSection(loaded)
27 topicsSection(loaded)
28 visibilitySection(loaded)
29 branchesSection(loaded)
30 mergeRulesSection(loaded)
31 daemonSection(loaded)
32 }
33 }
34 .overlay { LoadStateOverlay(state: model.state) }
35 .navigationTitle("Settings")
36 .navigationBarTitleDisplayMode(.inline)
37 .task {
38 await model.load()
39 if !loadedOnce, let loaded = model.state.value {
40 descriptionText = loaded.detail.description ?? ""
41 websiteText = loaded.settings.website ?? ""
42 loadedOnce = true
43 }
44 }
45 .refreshable { await model.load() }
46 }
47
48 // MARK: - Sections
49
50 private func aboutSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
51 Section("About") {
52 HStack {
53 TextField("Description", text: $descriptionText, axis: .vertical)
54 .lineLimit(1...3)
55 .accessibilityIdentifier("settings-description")
56 if descriptionText != (loaded.detail.description ?? "") {
57 Button("Save") {
58 Task { await model.setDescription(descriptionText) }
59 }
60 .font(.gbSans(.caption))
61 .disabled(model.working)
62 .accessibilityIdentifier("settings-description-save")
63 }
64 }
65 HStack {
66 TextField("Website", text: $websiteText)
67 .keyboardType(.URL)
68 .autocorrectionDisabled()
69 .textInputAutocapitalization(.never)
70 .accessibilityIdentifier("settings-website")
71 if websiteText != (loaded.settings.website ?? "") {
72 Button("Save") {
73 Task { await model.setWebsite(websiteText) }
74 }
75 .font(.gbSans(.caption))
76 .disabled(model.working)
77 }
78 }
79 }
80 }
81
82 private func topicsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
83 Section("Topics") {
84 if let topics = loaded.detail.topics, !topics.isEmpty {
85 ScrollView(.horizontal, showsIndicators: false) {
86 HStack(spacing: 6) {
87 ForEach(topics, id: \.self) { topic in
88 HStack(spacing: 3) {
89 Text(topic)
90 Button {
91 Task { await model.removeTopic(topic) }
92 } label: {
93 Image(systemName: "xmark.circle.fill")
94 .foregroundStyle(.tertiary)
95 }
96 .disabled(model.working)
97 }
98 .font(.gbSans(.caption))
99 .foregroundStyle(Color.gbAccent)
100 .padding(.horizontal, 8)
101 .padding(.vertical, 3)
102 .background(Color.gbAccent.opacity(0.07), in: gbChipShape)
103 .overlay(gbChipShape.stroke(Color.gbAccent.opacity(0.35), lineWidth: 1))
104 }
105 }
106 }
107 }
108 HStack {
109 TextField("Add topic", text: $newTopic)
110 .autocorrectionDisabled()
111 .textInputAutocapitalization(.never)
112 .accessibilityIdentifier("settings-add-topic")
113 Button {
114 let topic = newTopic.trimmingCharacters(in: .whitespaces)
115 newTopic = ""
116 Task { await model.addTopic(topic) }
117 } label: {
118 Image(systemName: "plus.circle.fill")
119 }
120 .disabled(newTopic.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
121 .accessibilityIdentifier("settings-add-topic-submit")
122 }
123 }
124 }
125
126 private func visibilitySection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
127 Section {
128 Picker("Visibility", selection: Binding(
129 get: { loaded.detail.visibility },
130 set: { newValue in Task { await model.setVisibility(newValue) } }
131 )) {
132 Text("Public").tag("public")
133 Text("Private").tag("private")
134 }
135 .disabled(model.working)
136 } footer: {
137 Text("Private repositories are visible only to the owner and granted accounts.")
138 }
139 }
140
141 private func branchesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
142 Section {
143 ForEach(loaded.settings.branches, id: \.self) { branch in
144 HStack {
145 Label(branch, systemImage: "lock.shield")
146 .font(.gbSans(.subheadline))
147 Spacer()
148 Button("Unprotect") {
149 Task { await model.unprotectBranch(branch) }
150 }
151 .font(.gbSans(.caption))
152 .disabled(model.working)
153 }
154 }
155 HStack {
156 TextField("Protect branch", text: $newBranch)
157 .autocorrectionDisabled()
158 .textInputAutocapitalization(.never)
159 Button {
160 let branch = newBranch.trimmingCharacters(in: .whitespaces)
161 newBranch = ""
162 Task { await model.protectBranch(branch) }
163 } label: {
164 Image(systemName: "plus.circle.fill")
165 }
166 .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
167 }
168 } header: {
169 Text("Protected branches")
170 } footer: {
171 Text("Protected branches refuse force pushes and deletion.")
172 }
173 }
174
175 private func mergeRulesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
176 Section {
177 ApprovalsStepper(
178 serverValue: loaded.settings.requireApprovals ?? 0
179 ) { newValue in
180 await model.setRequireApprovals(newValue)
181 }
182 .disabled(model.working)
183 toggle("Require threads resolved", loaded.settings.requireResolved ?? false) {
184 await model.setRequireResolved($0)
185 }
186 toggle("Require green checks", loaded.settings.requireChecks ?? false) {
187 await model.setRequireChecks($0)
188 }
189 toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
190 await model.setRequireSigned($0)
191 }
192 } header: {
193 Text("Merge requirements")
194 } footer: {
195 Text("The server enforces these on every merge, whatever the surface.")
196 }
197 }
198
199 private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
200 Section {
201 toggle("git:// daemon", loaded.settings.gitDaemon ?? false) {
202 await model.setGitDaemon($0)
203 }
204 } footer: {
205 Text("Anonymous, unauthenticated read access over the git protocol.")
206 }
207 }
208
209 private func toggle(
210 _ title: String,
211 _ value: Bool,
212 set: @escaping (Bool) async -> Void
213 ) -> some View {
214 SettingToggle(title: title, serverValue: value, write: set)
215 .disabled(model.working)
216 }
217}
218
219/// Same local-state mirror as SettingToggle, for the approvals count.
220private struct ApprovalsStepper: View {
221
222 let serverValue: Int
223 let write: (Int) async -> Void
224
225 @State private var count = 0
226 @State private var seeded = false
227
228 var body: some View {
229 Stepper("Required approvals: \(count)", value: $count, in: 0...10)
230 .onAppear {
231 if !seeded {
232 count = serverValue
233 seeded = true
234 }
235 }
236 .onChange(of: serverValue) { _, newValue in
237 count = newValue
238 }
239 .onChange(of: count) { _, newValue in
240 guard newValue != serverValue else { return }
241 Task { await write(newValue) }
242 }
243 }
244}
245
246/// A Toggle backed by real local state that mirrors the server value.
247/// A computed Binding whose setter spawns a Task proved unreliable under
248/// synthesized taps; a plain @State toggle is not.
249private struct SettingToggle: View {
250
251 let title: String
252 let serverValue: Bool
253 let write: (Bool) async -> Void
254
255 @State private var isOn = false
256 @State private var seeded = false
257
258 var body: some View {
259 Toggle(title, isOn: $isOn)
260 .onAppear {
261 if !seeded {
262 isOn = serverValue
263 seeded = true
264 }
265 }
266 .onChange(of: serverValue) { _, newValue in
267 isOn = newValue
268 }
269 .onChange(of: isOn) { _, newValue in
270 guard newValue != serverValue else { return }
271 Task { await write(newValue) }
272 }
273 }
274}