gitbay/Views/Repos/RepoSettingsView.swift

v1.7.0
gitbay-ios/gitbay/Views/Repos/RepoSettingsView.swift history · blame · raw

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}