gitbay/Views/Repos/RepoSettingsView.swift
632 lines · 25781 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 newTagGlob = ""
13 @State private var newContext = ""
14 @State private var renaming = false
15 @State private var newName = ""
16 @State private var renameConfirm = ""
17 /// Set once a rename succeeds: this screen's repository is gone and
18 /// the renamed one is a value push away.
19 @State private var renamedTo: String?
20 @State private var loadedOnce = false
21 @State private var removingTopic: String?
22 @State private var newGrantUser = ""
23 @State private var newGrantRole = "read"
24 @State private var revokingGrant: AccessGrant?
25
26 init(client: GitbayClient, repo: String) {
27 _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo))
28 }
29
30 var body: some View {
31 List {
32 if let renamedTo {
33 Section {
34 NavigationLink(value: RepoRoute.repo(renamedTo)) {
35 Label("Open \(renamedTo)", systemImage: "arrow.right.circle")
36 .font(.gbSans(.subheadline))
37 }
38 .accessibilityIdentifier("settings-renamed-link")
39 } header: {
40 Text("Renamed")
41 } footer: {
42 Text("This screen's repository moved; nothing here applies to it any more.")
43 }
44 }
45 if let loaded = model.state.value {
46 if let error = model.actionError {
47 Section {
48 GBNotice(error, .gbWarn)
49 }
50 }
51 aboutSection(loaded)
52 topicsSection(loaded)
53 visibilitySection(loaded)
54 defaultBranchSection(loaded)
55 branchesSection(loaded)
56 tagsSection(loaded)
57 mergeRulesSection(loaded)
58 daemonSection(loaded)
59 accessSection
60 webhooksSection
61 depsSection()
62 renameSection
63 }
64 }
65 .overlay { LoadStateOverlay(state: model.state) }
66 .navigationTitle("Settings")
67 .navigationBarTitleDisplayMode(.inline)
68 .task {
69 await model.load()
70 if !loadedOnce, let loaded = model.state.value {
71 descriptionText = loaded.detail.description ?? ""
72 websiteText = loaded.settings.website ?? ""
73 loadedOnce = true
74 }
75 }
76 .task { await model.loadDeps() }
77 .task { await model.loadGrants() }
78 .task { await model.loadBranches() }
79 .refreshable {
80 await model.load()
81 await model.loadDeps()
82 await model.loadGrants()
83 await model.loadBranches()
84 }
85 .confirmationDialog(
86 "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?",
87 isPresented: Binding(
88 get: { revokingGrant != nil },
89 set: { if !$0 { revokingGrant = nil } }
90 )
91 ) {
92 Button("Revoke", role: .destructive) {
93 if let grant = revokingGrant {
94 Task { await model.revoke(user: grant.user) }
95 }
96 revokingGrant = nil
97 }
98 Button("Cancel", role: .cancel) {}
99 }
100 .alert("Rename \(model.repoPath)", isPresented: $renaming) {
101 TextField("New name", text: $newName)
102 .autocorrectionDisabled()
103 .textInputAutocapitalization(.never)
104 TextField("type \(model.repoPath) to confirm", text: $renameConfirm)
105 .autocorrectionDisabled()
106 .textInputAutocapitalization(.never)
107 Button("Rename") {
108 Task {
109 if let path = await model.rename(to: newName) {
110 renamedTo = path
111 }
112 }
113 }
114 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty
115 || renameConfirm != model.repoPath)
116 Button("Cancel", role: .cancel) {}
117 } message: {
118 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
119 }
120 .confirmationDialog(
121 "Remove topic \(removingTopic ?? "")?",
122 isPresented: Binding(
123 get: { removingTopic != nil },
124 set: { if !$0 { removingTopic = nil } }
125 )
126 ) {
127 Button("Remove", role: .destructive) {
128 if let topic = removingTopic {
129 Task { await model.removeTopic(topic) }
130 }
131 removingTopic = nil
132 }
133 Button("Cancel", role: .cancel) {}
134 }
135 }
136
137 // MARK: - Sections
138
139 private func aboutSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
140 Section("About") {
141 HStack {
142 TextField("Description", text: $descriptionText, axis: .vertical)
143 .lineLimit(1...3)
144 .accessibilityIdentifier("settings-description")
145 if descriptionText != (loaded.detail.description ?? "") {
146 Button("Save") {
147 Task { await model.setDescription(descriptionText) }
148 }
149 .font(.gbSans(.caption))
150 .disabled(model.working)
151 .accessibilityIdentifier("settings-description-save")
152 }
153 }
154 HStack {
155 TextField("Website", text: $websiteText)
156 .keyboardType(.URL)
157 .autocorrectionDisabled()
158 .textInputAutocapitalization(.never)
159 .accessibilityIdentifier("settings-website")
160 if websiteText != (loaded.settings.website ?? "") {
161 Button("Save") {
162 Task { await model.setWebsite(websiteText) }
163 }
164 .font(.gbSans(.caption))
165 .disabled(model.working)
166 }
167 }
168 }
169 }
170
171 private func topicsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
172 Section("Topics") {
173 if let topics = loaded.detail.topics, !topics.isEmpty {
174 ScrollView(.horizontal, showsIndicators: false) {
175 HStack(spacing: 6) {
176 ForEach(topics, id: \.self) { topic in
177 HStack(spacing: 3) {
178 Text(topic)
179 Button {
180 removingTopic = topic
181 } label: {
182 Image(systemName: "xmark.circle.fill")
183 .foregroundStyle(.tertiary)
184 }
185 .accessibilityLabel("Remove \(topic)")
186 .disabled(model.working)
187 .accessibilityIdentifier("settings-remove-topic-\(topic)")
188 }
189 .font(.gbSans(.caption))
190 .foregroundStyle(Color.gbAccent)
191 .padding(.horizontal, 8)
192 .padding(.vertical, 3)
193 .background(Color.gbAccent.opacity(0.07), in: gbChipShape)
194 .overlay(gbChipShape.stroke(Color.gbAccent.opacity(0.35), lineWidth: 1))
195 }
196 }
197 }
198 }
199 HStack {
200 TextField("Add topic", text: $newTopic)
201 .autocorrectionDisabled()
202 .textInputAutocapitalization(.never)
203 .accessibilityIdentifier("settings-add-topic")
204 Button {
205 let topic = newTopic.trimmingCharacters(in: .whitespaces)
206 newTopic = ""
207 Task { await model.addTopic(topic) }
208 } label: {
209 Image(systemName: "plus.circle.fill")
210 }
211 .accessibilityLabel("Add topic")
212 .disabled(newTopic.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
213 .accessibilityIdentifier("settings-add-topic-submit")
214 }
215 }
216 }
217
218 private func visibilitySection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
219 Section {
220 Picker("Visibility", selection: Binding(
221 get: { loaded.detail.visibility },
222 set: { newValue in Task { await model.setVisibility(newValue) } }
223 )) {
224 Text("Public").tag("public")
225 Text("Private").tag("private")
226 }
227 .disabled(model.working)
228 } footer: {
229 Text("Private repositories are visible only to the owner and granted accounts.")
230 }
231 }
232
233 private func branchesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
234 Section {
235 ForEach(loaded.settings.branches, id: \.self) { branch in
236 HStack {
237 Label(branch, systemImage: "lock.shield")
238 .font(.gbSans(.subheadline))
239 Spacer()
240 Button("Unprotect") {
241 Task { await model.unprotectBranch(branch) }
242 }
243 .font(.gbSans(.caption))
244 .disabled(model.working)
245 }
246 }
247 HStack {
248 TextField("Protect branch", text: $newBranch)
249 .autocorrectionDisabled()
250 .textInputAutocapitalization(.never)
251 Button {
252 let branch = newBranch.trimmingCharacters(in: .whitespaces)
253 newBranch = ""
254 Task { await model.protectBranch(branch) }
255 } label: {
256 Image(systemName: "plus.circle.fill")
257 }
258 .accessibilityLabel("Protect branch")
259 .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
260 }
261 toggle("Merge requests only", loaded.settings.requireMR ?? false) {
262 await model.setRequireMR($0)
263 }
264 } header: {
265 Text("Protected branches")
266 } footer: {
267 Text("Protected branches refuse force pushes and deletion. Merge requests only also refuses direct pushes to them; merging a merge request becomes the only way to change them.")
268 }
269 }
270
271 /// `repo settings default-branch`: a menu over the branches the
272 /// server listed, since it refuses a name that does not exist.
273 private func defaultBranchSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
274 Section {
275 Menu {
276 ForEach(model.branches, id: \.self) { branch in
277 Button(branch) {
278 if branch != loaded.detail.defaultBranch {
279 Task { await model.setDefaultBranch(branch) }
280 }
281 }
282 }
283 } label: {
284 HStack {
285 Label(loaded.detail.defaultBranch, systemImage: "arrow.triangle.branch")
286 .font(.gbSans(.subheadline))
287 Spacer()
288 Image(systemName: "chevron.up.chevron.down")
289 .font(.gbSans(.caption))
290 .foregroundStyle(.secondary)
291 }
292 }
293 .disabled(model.working || model.branches.isEmpty)
294 .accessibilityIdentifier("settings-default-branch-menu")
295 } header: {
296 Text("Default branch")
297 } footer: {
298 Text("What a clone checks out, and what merge requests target by default.")
299 }
300 }
301
302 /// `repo settings protect-tag` / `unprotect-tag`: globs, path.Match style.
303 private func tagsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
304 Section {
305 ForEach(loaded.settings.tags, id: \.self) { glob in
306 HStack {
307 Label(glob, systemImage: "tag")
308 .font(.gbMono(.caption))
309 Spacer()
310 Button("Unprotect") {
311 Task { await model.unprotectTag(glob) }
312 }
313 .font(.gbSans(.caption))
314 .disabled(model.working)
315 .accessibilityIdentifier("settings-unprotect-tag-\(glob)")
316 }
317 }
318 HStack {
319 TextField("Protect tags matching", text: $newTagGlob)
320 .autocorrectionDisabled()
321 .textInputAutocapitalization(.never)
322 .accessibilityIdentifier("settings-protect-tag")
323 Button {
324 let glob = newTagGlob.trimmingCharacters(in: .whitespaces)
325 newTagGlob = ""
326 Task { await model.protectTag(glob) }
327 } label: {
328 Image(systemName: "plus.circle.fill")
329 }
330 .accessibilityLabel("Protect tag")
331 .disabled(newTagGlob.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
332 .accessibilityIdentifier("settings-protect-tag-submit")
333 }
334 } header: {
335 Text("Protected tags")
336 } footer: {
337 Text("A matching tag is created once and then refuses moves and deletion. Globs such as v*.")
338 }
339 }
340
341 private func mergeRulesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
342 Section {
343 ApprovalsStepper(
344 serverValue: loaded.settings.requireApprovals ?? 0
345 ) { newValue in
346 await model.setRequireApprovals(newValue)
347 }
348 .disabled(model.working)
349 toggle("Require threads resolved", loaded.settings.requireResolved ?? false) {
350 await model.setRequireResolved($0)
351 }
352 toggle("Require green checks", loaded.settings.requireChecks ?? false) {
353 await model.setRequireChecks($0)
354 }
355 VStack(alignment: .leading, spacing: 6) {
356 Text(contextsHeading(loaded.settings))
357 .font(.gbSans(.subheadline))
358 RemovableChips(items: loaded.settings.contexts, idPrefix: "settings-context",
359 disabled: model.working,
360 remove: { context in Task { await model.removeRequiredContext(context) } })
361 HStack {
362 TextField("ci/test", text: $newContext)
363 .font(.gbMono(.subheadline))
364 .autocorrectionDisabled()
365 .textInputAutocapitalization(.never)
366 .onSubmit { if !model.working { addContext() } }
367 .accessibilityIdentifier("settings-context-field")
368 Button {
369 addContext()
370 } label: {
371 Image(systemName: "plus.circle.fill")
372 }
373 .accessibilityLabel("Require check")
374 .disabled(newContext.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
375 .accessibilityIdentifier("settings-context-add")
376 }
377 }
378 toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
379 await model.setRequireSigned($0)
380 }
381 toggle("Require code owner review", loaded.settings.requireCodeowners ?? false) {
382 await model.setRequireCodeowners($0)
383 }
384 } header: {
385 Text("Merge requirements")
386 } footer: {
387 Text("The server enforces these on every merge, whatever the surface. Naming a check turns the checks gate on.")
388 }
389 }
390
391 private func addContext() {
392 let context = newContext
393 Task {
394 if await model.addRequiredContext(context), newContext == context { newContext = "" }
395 }
396 }
397
398 private func contextsHeading(_ settings: RepoSettings) -> String {
399 if !(settings.requireChecks ?? false) {
400 return settings.contexts.isEmpty ? "Name a check to require it" : "Named checks, not required while the gate is off"
401 }
402 return settings.contexts.isEmpty ? "Every check on the head must pass" : "These checks must pass"
403 }
404
405 private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
406 Section {
407 toggle("git:// daemon", loaded.settings.gitDaemon ?? false) {
408 await model.setGitDaemon($0)
409 }
410 } footer: {
411 Text("Anonymous, unauthenticated read access over the git protocol.")
412 }
413 }
414
415 /// `repo access list`, grant and revoke. Owners and org members are
416 /// not grants and do not appear here.
417 private var accessSection: some View {
418 Section {
419 if let error = model.grantsError {
420 GBNotice(error, .gbWarn)
421 }
422 ForEach(model.grants ?? []) { grant in
423 HStack {
424 NavigationLink(value: RepoRoute.profile(grant.user)) {
425 Text(grant.user)
426 .font(.gbSans(.subheadline))
427 }
428 Spacer()
429 if let source = grant.source, source != "direct" {
430 GBChip(source, .secondary)
431 }
432 Menu {
433 ForEach(["read", "write", "admin"], id: \.self) { role in
434 Button(role) { Task { await model.grant(user: grant.user, role: role) } }
435 }
436 } label: {
437 GBChip(grant.role, .secondary)
438 }
439 .disabled(model.working)
440 }
441 .swipeActions {
442 if grant.isDirect {
443 Button("Revoke", role: .destructive) {
444 revokingGrant = grant
445 }
446 }
447 }
448 }
449 VStack(spacing: 8) {
450 TextField("Grant user", text: $newGrantUser)
451 .autocorrectionDisabled()
452 .textInputAutocapitalization(.never)
453 .accessibilityIdentifier("settings-grant-user")
454 HStack {
455 Picker("Role", selection: $newGrantRole) {
456 Text("read").tag("read")
457 Text("write").tag("write")
458 Text("admin").tag("admin")
459 }
460 .pickerStyle(.segmented)
461 Button("Grant") {
462 let user = newGrantUser
463 newGrantUser = ""
464 Task { await model.grant(user: user, role: newGrantRole) }
465 }
466 .buttonStyle(.borderless)
467 .disabled(newGrantUser.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
468 .accessibilityIdentifier("settings-grant-submit")
469 }
470 }
471 } header: {
472 Text("Access")
473 } footer: {
474 Text("Everyone who can reach this repository and how. Only a direct grant can be revoked here; a role change grants directly.")
475 }
476 }
477
478 private var webhooksSection: some View {
479 Section {
480 NavigationLink(value: RepoRoute.webhooks(repo: model.repoPath)) {
481 Label("Webhooks", systemImage: "antenna.radiowaves.left.and.right")
482 .font(.gbSans(.subheadline))
483 }
484 .accessibilityIdentifier("settings-webhooks-link")
485 }
486 }
487
488 /// `repo rename`. Delete and transfer stay SSH-only by design; a
489 /// rename keeps the owner and is routine for someone with many repos.
490 private var renameSection: some View {
491 Section {
492 Button {
493 newName = String(model.repoPath.split(separator: "/").last ?? "")
494 renameConfirm = ""
495 renaming = true
496 } label: {
497 Label("Rename repository", systemImage: "pencil")
498 .font(.gbSans(.subheadline))
499 }
500 .disabled(model.working)
501 .accessibilityIdentifier("settings-rename-button")
502 } footer: {
503 Text("The owner stays; the name changes, and so do the clone URLs.")
504 }
505 }
506
507 private func depsSection() -> some View {
508 Section {
509 if let deps = model.deps {
510 SettingToggle(title: "Dependency checks", serverValue: deps.enabled) {
511 await model.setDepsEnabled($0)
512 }
513 .disabled(model.working)
514
515 if deps.enabled {
516 if let lastCheck = deps.lastCheck {
517 (Text("Last checked ") + Text(lastCheck, format: .relative(presentation: .named)))
518 .font(.gbSans(.caption))
519 .foregroundStyle(.secondary)
520 } else {
521 Text("Never checked")
522 .font(.gbSans(.caption))
523 .foregroundStyle(.secondary)
524 }
525 if let lastError = deps.lastError {
526 GBNotice(lastError, .gbWarn)
527 }
528 if let issueNumber = deps.issueNumber {
529 NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issueNumber)) {
530 Text("Tracking issue #\(issueNumber)")
531 }
532 }
533 if deps.behind.isEmpty {
534 if deps.lastCheck != nil {
535 Text("Everything is current.")
536 .font(.gbSans(.caption))
537 .foregroundStyle(.secondary)
538 }
539 } else {
540 ForEach(deps.behind) { dep in
541 VStack(alignment: .leading, spacing: 2) {
542 Text(dep.name)
543 .font(.gbSans(.subheadline))
544 Text("\(dep.ecosystem) · \(dep.current) → \(dep.latest)")
545 .font(.gbSans(.caption))
546 .foregroundStyle(.secondary)
547 }
548 }
549 }
550 }
551 } else if let depsError = model.depsError {
552 GBNotice(depsError, .gbWarn)
553 } else {
554 HStack {
555 Spacer()
556 ProgressView()
557 Spacer()
558 }
559 }
560 } header: {
561 Text("Dependencies")
562 } footer: {
563 Text("Checks tell a public registry what this repository depends on.")
564 }
565 }
566
567 private func toggle(
568 _ title: String,
569 _ value: Bool,
570 set: @escaping (Bool) async -> Void
571 ) -> some View {
572 SettingToggle(title: title, serverValue: value, write: set)
573 .disabled(model.working)
574 }
575}
576
577/// Same local-state mirror as SettingToggle, for the approvals count.
578private struct ApprovalsStepper: View {
579
580 let serverValue: Int
581 let write: (Int) async -> Void
582
583 @State private var count = 0
584 @State private var seeded = false
585
586 var body: some View {
587 Stepper("Required approvals: \(count)", value: $count, in: 0...10)
588 .onAppear {
589 if !seeded {
590 count = serverValue
591 seeded = true
592 }
593 }
594 .onChange(of: serverValue) { _, newValue in
595 count = newValue
596 }
597 .onChange(of: count) { _, newValue in
598 guard newValue != serverValue else { return }
599 Task { await write(newValue) }
600 }
601 }
602}
603
604/// A Toggle backed by real local state that mirrors the server value.
605/// A computed Binding whose setter spawns a Task proved unreliable under
606/// synthesized taps; a plain @State toggle is not.
607struct SettingToggle: View {
608
609 let title: String
610 let serverValue: Bool
611 let write: (Bool) async -> Void
612
613 @State private var isOn = false
614 @State private var seeded = false
615
616 var body: some View {
617 Toggle(title, isOn: $isOn)
618 .onAppear {
619 if !seeded {
620 isOn = serverValue
621 seeded = true
622 }
623 }
624 .onChange(of: serverValue) { _, newValue in
625 isOn = newValue
626 }
627 .onChange(of: isOn) { _, newValue in
628 guard newValue != serverValue else { return }
629 Task { await write(newValue) }
630 }
631 }
632}