gitbay/Views/Repos/RepoSettingsView.swift

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

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}