gitbay/Views/Repos/RepoSettingsView.swift

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

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}