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