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