gitbay/Views/Repos/RepoSettingsView.swift
449 lines · 17396 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 @State private var removingTopic: String?
14 @State private var newGrantUser = ""
15 @State private var newGrantRole = "read"
16 @State private var revokingGrant: AccessGrant?
17
18 init(client: GitbayClient, repo: String) {
19 _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo))
20 }
21
22 var body: some View {
23 List {
24 if let loaded = model.state.value {
25 if let error = model.actionError {
26 Section {
27 GBNotice(error, .gbWarn)
28 }
29 }
30 aboutSection(loaded)
31 topicsSection(loaded)
32 visibilitySection(loaded)
33 branchesSection(loaded)
34 mergeRulesSection(loaded)
35 daemonSection(loaded)
36 accessSection
37 webhooksSection
38 depsSection()
39 }
40 }
41 .overlay { LoadStateOverlay(state: model.state) }
42 .navigationTitle("Settings")
43 .navigationBarTitleDisplayMode(.inline)
44 .task {
45 await model.load()
46 if !loadedOnce, let loaded = model.state.value {
47 descriptionText = loaded.detail.description ?? ""
48 websiteText = loaded.settings.website ?? ""
49 loadedOnce = true
50 }
51 }
52 .task { await model.loadDeps() }
53 .task { await model.loadGrants() }
54 .refreshable {
55 await model.load()
56 await model.loadDeps()
57 await model.loadGrants()
58 }
59 .confirmationDialog(
60 "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?",
61 isPresented: Binding(
62 get: { revokingGrant != nil },
63 set: { if !$0 { revokingGrant = nil } }
64 )
65 ) {
66 Button("Revoke", role: .destructive) {
67 if let grant = revokingGrant {
68 Task { await model.revoke(user: grant.user) }
69 }
70 revokingGrant = nil
71 }
72 Button("Cancel", role: .cancel) {}
73 }
74 .confirmationDialog(
75 "Remove topic \(removingTopic ?? "")?",
76 isPresented: Binding(
77 get: { removingTopic != nil },
78 set: { if !$0 { removingTopic = nil } }
79 )
80 ) {
81 Button("Remove", role: .destructive) {
82 if let topic = removingTopic {
83 Task { await model.removeTopic(topic) }
84 }
85 removingTopic = nil
86 }
87 Button("Cancel", role: .cancel) {}
88 }
89 }
90
91 // MARK: - Sections
92
93 private func aboutSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
94 Section("About") {
95 HStack {
96 TextField("Description", text: $descriptionText, axis: .vertical)
97 .lineLimit(1...3)
98 .accessibilityIdentifier("settings-description")
99 if descriptionText != (loaded.detail.description ?? "") {
100 Button("Save") {
101 Task { await model.setDescription(descriptionText) }
102 }
103 .font(.gbSans(.caption))
104 .disabled(model.working)
105 .accessibilityIdentifier("settings-description-save")
106 }
107 }
108 HStack {
109 TextField("Website", text: $websiteText)
110 .keyboardType(.URL)
111 .autocorrectionDisabled()
112 .textInputAutocapitalization(.never)
113 .accessibilityIdentifier("settings-website")
114 if websiteText != (loaded.settings.website ?? "") {
115 Button("Save") {
116 Task { await model.setWebsite(websiteText) }
117 }
118 .font(.gbSans(.caption))
119 .disabled(model.working)
120 }
121 }
122 }
123 }
124
125 private func topicsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
126 Section("Topics") {
127 if let topics = loaded.detail.topics, !topics.isEmpty {
128 ScrollView(.horizontal, showsIndicators: false) {
129 HStack(spacing: 6) {
130 ForEach(topics, id: \.self) { topic in
131 HStack(spacing: 3) {
132 Text(topic)
133 Button {
134 removingTopic = topic
135 } label: {
136 Image(systemName: "xmark.circle.fill")
137 .foregroundStyle(.tertiary)
138 }
139 .disabled(model.working)
140 .accessibilityIdentifier("settings-remove-topic-\(topic)")
141 }
142 .font(.gbSans(.caption))
143 .foregroundStyle(Color.gbAccent)
144 .padding(.horizontal, 8)
145 .padding(.vertical, 3)
146 .background(Color.gbAccent.opacity(0.07), in: gbChipShape)
147 .overlay(gbChipShape.stroke(Color.gbAccent.opacity(0.35), lineWidth: 1))
148 }
149 }
150 }
151 }
152 HStack {
153 TextField("Add topic", text: $newTopic)
154 .autocorrectionDisabled()
155 .textInputAutocapitalization(.never)
156 .accessibilityIdentifier("settings-add-topic")
157 Button {
158 let topic = newTopic.trimmingCharacters(in: .whitespaces)
159 newTopic = ""
160 Task { await model.addTopic(topic) }
161 } label: {
162 Image(systemName: "plus.circle.fill")
163 }
164 .disabled(newTopic.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
165 .accessibilityIdentifier("settings-add-topic-submit")
166 }
167 }
168 }
169
170 private func visibilitySection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
171 Section {
172 Picker("Visibility", selection: Binding(
173 get: { loaded.detail.visibility },
174 set: { newValue in Task { await model.setVisibility(newValue) } }
175 )) {
176 Text("Public").tag("public")
177 Text("Private").tag("private")
178 }
179 .disabled(model.working)
180 } footer: {
181 Text("Private repositories are visible only to the owner and granted accounts.")
182 }
183 }
184
185 private func branchesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
186 Section {
187 ForEach(loaded.settings.branches, id: \.self) { branch in
188 HStack {
189 Label(branch, systemImage: "lock.shield")
190 .font(.gbSans(.subheadline))
191 Spacer()
192 Button("Unprotect") {
193 Task { await model.unprotectBranch(branch) }
194 }
195 .font(.gbSans(.caption))
196 .disabled(model.working)
197 }
198 }
199 HStack {
200 TextField("Protect branch", text: $newBranch)
201 .autocorrectionDisabled()
202 .textInputAutocapitalization(.never)
203 Button {
204 let branch = newBranch.trimmingCharacters(in: .whitespaces)
205 newBranch = ""
206 Task { await model.protectBranch(branch) }
207 } label: {
208 Image(systemName: "plus.circle.fill")
209 }
210 .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
211 }
212 } header: {
213 Text("Protected branches")
214 } footer: {
215 Text("Protected branches refuse force pushes and deletion.")
216 }
217 }
218
219 private func mergeRulesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
220 Section {
221 ApprovalsStepper(
222 serverValue: loaded.settings.requireApprovals ?? 0
223 ) { newValue in
224 await model.setRequireApprovals(newValue)
225 }
226 .disabled(model.working)
227 toggle("Require threads resolved", loaded.settings.requireResolved ?? false) {
228 await model.setRequireResolved($0)
229 }
230 toggle("Require green checks", loaded.settings.requireChecks ?? false) {
231 await model.setRequireChecks($0)
232 }
233 toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
234 await model.setRequireSigned($0)
235 }
236 toggle("Require code owner review", loaded.settings.requireCodeowners ?? false) {
237 await model.setRequireCodeowners($0)
238 }
239 } header: {
240 Text("Merge requirements")
241 } footer: {
242 Text("The server enforces these on every merge, whatever the surface.")
243 }
244 }
245
246 private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
247 Section {
248 toggle("git:// daemon", loaded.settings.gitDaemon ?? false) {
249 await model.setGitDaemon($0)
250 }
251 } footer: {
252 Text("Anonymous, unauthenticated read access over the git protocol.")
253 }
254 }
255
256 /// `repo access list`, grant and revoke. Owners and org members are
257 /// not grants and do not appear here.
258 private var accessSection: some View {
259 Section {
260 if let error = model.grantsError {
261 GBNotice(error, .gbWarn)
262 }
263 ForEach(model.grants ?? []) { grant in
264 HStack {
265 NavigationLink(value: RepoRoute.profile(grant.user)) {
266 Text(grant.user)
267 .font(.gbSans(.subheadline))
268 }
269 Spacer()
270 Menu {
271 ForEach(["read", "write", "admin"], id: \.self) { role in
272 Button(role) { Task { await model.grant(user: grant.user, role: role) } }
273 }
274 } label: {
275 GBChip(grant.role, .secondary)
276 }
277 .disabled(model.working)
278 }
279 .swipeActions {
280 Button("Revoke", role: .destructive) {
281 revokingGrant = grant
282 }
283 }
284 }
285 VStack(spacing: 8) {
286 TextField("Grant user", text: $newGrantUser)
287 .autocorrectionDisabled()
288 .textInputAutocapitalization(.never)
289 .accessibilityIdentifier("settings-grant-user")
290 HStack {
291 Picker("Role", selection: $newGrantRole) {
292 Text("read").tag("read")
293 Text("write").tag("write")
294 Text("admin").tag("admin")
295 }
296 .pickerStyle(.segmented)
297 Button("Grant") {
298 let user = newGrantUser
299 newGrantUser = ""
300 Task { await model.grant(user: user, role: newGrantRole) }
301 }
302 .buttonStyle(.borderless)
303 .disabled(newGrantUser.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
304 .accessibilityIdentifier("settings-grant-submit")
305 }
306 }
307 } header: {
308 Text("Access")
309 } footer: {
310 Text("Direct grants only. Ownership and org membership carry their own access.")
311 }
312 }
313
314 private var webhooksSection: some View {
315 Section {
316 NavigationLink(value: RepoRoute.webhooks(repo: model.repoPath)) {
317 Label("Webhooks", systemImage: "antenna.radiowaves.left.and.right")
318 .font(.gbSans(.subheadline))
319 }
320 .accessibilityIdentifier("settings-webhooks-link")
321 }
322 }
323
324 private func depsSection() -> some View {
325 Section {
326 if let deps = model.deps {
327 SettingToggle(title: "Dependency checks", serverValue: deps.enabled) {
328 await model.setDepsEnabled($0)
329 }
330 .disabled(model.working)
331
332 if deps.enabled {
333 if let lastCheck = deps.lastCheck {
334 (Text("Last checked ") + Text(lastCheck, format: .relative(presentation: .named)))
335 .font(.gbSans(.caption))
336 .foregroundStyle(.secondary)
337 } else {
338 Text("Never checked")
339 .font(.gbSans(.caption))
340 .foregroundStyle(.secondary)
341 }
342 if let lastError = deps.lastError {
343 GBNotice(lastError, .gbWarn)
344 }
345 if let issueNumber = deps.issueNumber {
346 NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issueNumber)) {
347 Text("Tracking issue #\(issueNumber)")
348 }
349 }
350 if deps.behind.isEmpty {
351 if deps.lastCheck != nil {
352 Text("Everything is current.")
353 .font(.gbSans(.caption))
354 .foregroundStyle(.secondary)
355 }
356 } else {
357 ForEach(deps.behind) { dep in
358 VStack(alignment: .leading, spacing: 2) {
359 Text(dep.name)
360 .font(.gbSans(.subheadline))
361 Text("\(dep.ecosystem) · \(dep.current) → \(dep.latest)")
362 .font(.gbSans(.caption))
363 .foregroundStyle(.secondary)
364 }
365 }
366 }
367 }
368 } else if let depsError = model.depsError {
369 GBNotice(depsError, .gbWarn)
370 } else {
371 HStack {
372 Spacer()
373 ProgressView()
374 Spacer()
375 }
376 }
377 } header: {
378 Text("Dependencies")
379 } footer: {
380 Text("Checks tell a public registry what this repository depends on.")
381 }
382 }
383
384 private func toggle(
385 _ title: String,
386 _ value: Bool,
387 set: @escaping (Bool) async -> Void
388 ) -> some View {
389 SettingToggle(title: title, serverValue: value, write: set)
390 .disabled(model.working)
391 }
392}
393
394/// Same local-state mirror as SettingToggle, for the approvals count.
395private struct ApprovalsStepper: View {
396
397 let serverValue: Int
398 let write: (Int) async -> Void
399
400 @State private var count = 0
401 @State private var seeded = false
402
403 var body: some View {
404 Stepper("Required approvals: \(count)", value: $count, in: 0...10)
405 .onAppear {
406 if !seeded {
407 count = serverValue
408 seeded = true
409 }
410 }
411 .onChange(of: serverValue) { _, newValue in
412 count = newValue
413 }
414 .onChange(of: count) { _, newValue in
415 guard newValue != serverValue else { return }
416 Task { await write(newValue) }
417 }
418 }
419}
420
421/// A Toggle backed by real local state that mirrors the server value.
422/// A computed Binding whose setter spawns a Task proved unreliable under
423/// synthesized taps; a plain @State toggle is not.
424private struct SettingToggle: View {
425
426 let title: String
427 let serverValue: Bool
428 let write: (Bool) async -> Void
429
430 @State private var isOn = false
431 @State private var seeded = false
432
433 var body: some View {
434 Toggle(title, isOn: $isOn)
435 .onAppear {
436 if !seeded {
437 isOn = serverValue
438 seeded = true
439 }
440 }
441 .onChange(of: serverValue) { _, newValue in
442 isOn = newValue
443 }
444 .onChange(of: isOn) { _, newValue in
445 guard newValue != serverValue else { return }
446 Task { await write(newValue) }
447 }
448 }
449}