gitbay/Views/Repos/RepoSettingsView.swift

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

449 lines · 17396 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 loadedOnce = false
 13    @State private var removingTopic: String?
 14    @State private var newGrantUser = ""
 15    @State private var newGrantRole = "read"
 16    @State private var revokingGrant: AccessGrant?
 17
 18    init(client: GitbayClient, repo: String) {
 19        _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo))
 20    }
 21
 22    var body: some View {
 23        List {
 24            if let loaded = model.state.value {
 25                if let error = model.actionError {
 26                    Section {
 27                        GBNotice(error, .gbWarn)
 28                    }
 29                }
 30                aboutSection(loaded)
 31                topicsSection(loaded)
 32                visibilitySection(loaded)
 33                branchesSection(loaded)
 34                mergeRulesSection(loaded)
 35                daemonSection(loaded)
 36                accessSection
 37                webhooksSection
 38                depsSection()
 39            }
 40        }
 41        .overlay { LoadStateOverlay(state: model.state) }
 42        .navigationTitle("Settings")
 43        .navigationBarTitleDisplayMode(.inline)
 44        .task {
 45            await model.load()
 46            if !loadedOnce, let loaded = model.state.value {
 47                descriptionText = loaded.detail.description ?? ""
 48                websiteText = loaded.settings.website ?? ""
 49                loadedOnce = true
 50            }
 51        }
 52        .task { await model.loadDeps() }
 53        .task { await model.loadGrants() }
 54        .refreshable {
 55            await model.load()
 56            await model.loadDeps()
 57            await model.loadGrants()
 58        }
 59        .confirmationDialog(
 60            "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?",
 61            isPresented: Binding(
 62                get: { revokingGrant != nil },
 63                set: { if !$0 { revokingGrant = nil } }
 64            )
 65        ) {
 66            Button("Revoke", role: .destructive) {
 67                if let grant = revokingGrant {
 68                    Task { await model.revoke(user: grant.user) }
 69                }
 70                revokingGrant = nil
 71            }
 72            Button("Cancel", role: .cancel) {}
 73        }
 74        .confirmationDialog(
 75            "Remove topic \(removingTopic ?? "")?",
 76            isPresented: Binding(
 77                get: { removingTopic != nil },
 78                set: { if !$0 { removingTopic = nil } }
 79            )
 80        ) {
 81            Button("Remove", role: .destructive) {
 82                if let topic = removingTopic {
 83                    Task { await model.removeTopic(topic) }
 84                }
 85                removingTopic = nil
 86            }
 87            Button("Cancel", role: .cancel) {}
 88        }
 89    }
 90
 91    // MARK: - Sections
 92
 93    private func aboutSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
 94        Section("About") {
 95            HStack {
 96                TextField("Description", text: $descriptionText, axis: .vertical)
 97                    .lineLimit(1...3)
 98                    .accessibilityIdentifier("settings-description")
 99                if descriptionText != (loaded.detail.description ?? "") {
100                    Button("Save") {
101                        Task { await model.setDescription(descriptionText) }
102                    }
103                    .font(.gbSans(.caption))
104                    .disabled(model.working)
105                    .accessibilityIdentifier("settings-description-save")
106                }
107            }
108            HStack {
109                TextField("Website", text: $websiteText)
110                    .keyboardType(.URL)
111                    .autocorrectionDisabled()
112                    .textInputAutocapitalization(.never)
113                    .accessibilityIdentifier("settings-website")
114                if websiteText != (loaded.settings.website ?? "") {
115                    Button("Save") {
116                        Task { await model.setWebsite(websiteText) }
117                    }
118                    .font(.gbSans(.caption))
119                    .disabled(model.working)
120                }
121            }
122        }
123    }
124
125    private func topicsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
126        Section("Topics") {
127            if let topics = loaded.detail.topics, !topics.isEmpty {
128                ScrollView(.horizontal, showsIndicators: false) {
129                    HStack(spacing: 6) {
130                        ForEach(topics, id: \.self) { topic in
131                            HStack(spacing: 3) {
132                                Text(topic)
133                                Button {
134                                    removingTopic = topic
135                                } label: {
136                                    Image(systemName: "xmark.circle.fill")
137                                        .foregroundStyle(.tertiary)
138                                }
139                                .disabled(model.working)
140                                .accessibilityIdentifier("settings-remove-topic-\(topic)")
141                            }
142                            .font(.gbSans(.caption))
143                            .foregroundStyle(Color.gbAccent)
144                            .padding(.horizontal, 8)
145                            .padding(.vertical, 3)
146                            .background(Color.gbAccent.opacity(0.07), in: gbChipShape)
147                            .overlay(gbChipShape.stroke(Color.gbAccent.opacity(0.35), lineWidth: 1))
148                        }
149                    }
150                }
151            }
152            HStack {
153                TextField("Add topic", text: $newTopic)
154                    .autocorrectionDisabled()
155                    .textInputAutocapitalization(.never)
156                    .accessibilityIdentifier("settings-add-topic")
157                Button {
158                    let topic = newTopic.trimmingCharacters(in: .whitespaces)
159                    newTopic = ""
160                    Task { await model.addTopic(topic) }
161                } label: {
162                    Image(systemName: "plus.circle.fill")
163                }
164                .disabled(newTopic.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
165                .accessibilityIdentifier("settings-add-topic-submit")
166            }
167        }
168    }
169
170    private func visibilitySection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
171        Section {
172            Picker("Visibility", selection: Binding(
173                get: { loaded.detail.visibility },
174                set: { newValue in Task { await model.setVisibility(newValue) } }
175            )) {
176                Text("Public").tag("public")
177                Text("Private").tag("private")
178            }
179            .disabled(model.working)
180        } footer: {
181            Text("Private repositories are visible only to the owner and granted accounts.")
182        }
183    }
184
185    private func branchesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
186        Section {
187            ForEach(loaded.settings.branches, id: \.self) { branch in
188                HStack {
189                    Label(branch, systemImage: "lock.shield")
190                        .font(.gbSans(.subheadline))
191                    Spacer()
192                    Button("Unprotect") {
193                        Task { await model.unprotectBranch(branch) }
194                    }
195                    .font(.gbSans(.caption))
196                    .disabled(model.working)
197                }
198            }
199            HStack {
200                TextField("Protect branch", text: $newBranch)
201                    .autocorrectionDisabled()
202                    .textInputAutocapitalization(.never)
203                Button {
204                    let branch = newBranch.trimmingCharacters(in: .whitespaces)
205                    newBranch = ""
206                    Task { await model.protectBranch(branch) }
207                } label: {
208                    Image(systemName: "plus.circle.fill")
209                }
210                .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
211            }
212        } header: {
213            Text("Protected branches")
214        } footer: {
215            Text("Protected branches refuse force pushes and deletion.")
216        }
217    }
218
219    private func mergeRulesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
220        Section {
221            ApprovalsStepper(
222                serverValue: loaded.settings.requireApprovals ?? 0
223            ) { newValue in
224                await model.setRequireApprovals(newValue)
225            }
226            .disabled(model.working)
227            toggle("Require threads resolved", loaded.settings.requireResolved ?? false) {
228                await model.setRequireResolved($0)
229            }
230            toggle("Require green checks", loaded.settings.requireChecks ?? false) {
231                await model.setRequireChecks($0)
232            }
233            toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
234                await model.setRequireSigned($0)
235            }
236            toggle("Require code owner review", loaded.settings.requireCodeowners ?? false) {
237                await model.setRequireCodeowners($0)
238            }
239        } header: {
240            Text("Merge requirements")
241        } footer: {
242            Text("The server enforces these on every merge, whatever the surface.")
243        }
244    }
245
246    private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
247        Section {
248            toggle("git:// daemon", loaded.settings.gitDaemon ?? false) {
249                await model.setGitDaemon($0)
250            }
251        } footer: {
252            Text("Anonymous, unauthenticated read access over the git protocol.")
253        }
254    }
255
256    /// `repo access list`, grant and revoke. Owners and org members are
257    /// not grants and do not appear here.
258    private var accessSection: some View {
259        Section {
260            if let error = model.grantsError {
261                GBNotice(error, .gbWarn)
262            }
263            ForEach(model.grants ?? []) { grant in
264                HStack {
265                    NavigationLink(value: RepoRoute.profile(grant.user)) {
266                        Text(grant.user)
267                            .font(.gbSans(.subheadline))
268                    }
269                    Spacer()
270                    Menu {
271                        ForEach(["read", "write", "admin"], id: \.self) { role in
272                            Button(role) { Task { await model.grant(user: grant.user, role: role) } }
273                        }
274                    } label: {
275                        GBChip(grant.role, .secondary)
276                    }
277                    .disabled(model.working)
278                }
279                .swipeActions {
280                    Button("Revoke", role: .destructive) {
281                        revokingGrant = grant
282                    }
283                }
284            }
285            VStack(spacing: 8) {
286                TextField("Grant user", text: $newGrantUser)
287                    .autocorrectionDisabled()
288                    .textInputAutocapitalization(.never)
289                    .accessibilityIdentifier("settings-grant-user")
290                HStack {
291                    Picker("Role", selection: $newGrantRole) {
292                        Text("read").tag("read")
293                        Text("write").tag("write")
294                        Text("admin").tag("admin")
295                    }
296                    .pickerStyle(.segmented)
297                    Button("Grant") {
298                        let user = newGrantUser
299                        newGrantUser = ""
300                        Task { await model.grant(user: user, role: newGrantRole) }
301                    }
302                    .buttonStyle(.borderless)
303                    .disabled(newGrantUser.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
304                    .accessibilityIdentifier("settings-grant-submit")
305                }
306            }
307        } header: {
308            Text("Access")
309        } footer: {
310            Text("Direct grants only. Ownership and org membership carry their own access.")
311        }
312    }
313
314    private var webhooksSection: some View {
315        Section {
316            NavigationLink(value: RepoRoute.webhooks(repo: model.repoPath)) {
317                Label("Webhooks", systemImage: "antenna.radiowaves.left.and.right")
318                    .font(.gbSans(.subheadline))
319            }
320            .accessibilityIdentifier("settings-webhooks-link")
321        }
322    }
323
324    private func depsSection() -> some View {
325        Section {
326            if let deps = model.deps {
327                SettingToggle(title: "Dependency checks", serverValue: deps.enabled) {
328                    await model.setDepsEnabled($0)
329                }
330                .disabled(model.working)
331
332                if deps.enabled {
333                    if let lastCheck = deps.lastCheck {
334                        (Text("Last checked ") + Text(lastCheck, format: .relative(presentation: .named)))
335                            .font(.gbSans(.caption))
336                            .foregroundStyle(.secondary)
337                    } else {
338                        Text("Never checked")
339                            .font(.gbSans(.caption))
340                            .foregroundStyle(.secondary)
341                    }
342                    if let lastError = deps.lastError {
343                        GBNotice(lastError, .gbWarn)
344                    }
345                    if let issueNumber = deps.issueNumber {
346                        NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issueNumber)) {
347                            Text("Tracking issue #\(issueNumber)")
348                        }
349                    }
350                    if deps.behind.isEmpty {
351                        if deps.lastCheck != nil {
352                            Text("Everything is current.")
353                                .font(.gbSans(.caption))
354                                .foregroundStyle(.secondary)
355                        }
356                    } else {
357                        ForEach(deps.behind) { dep in
358                            VStack(alignment: .leading, spacing: 2) {
359                                Text(dep.name)
360                                    .font(.gbSans(.subheadline))
361                                Text("\(dep.ecosystem) · \(dep.current) → \(dep.latest)")
362                                    .font(.gbSans(.caption))
363                                    .foregroundStyle(.secondary)
364                            }
365                        }
366                    }
367                }
368            } else if let depsError = model.depsError {
369                GBNotice(depsError, .gbWarn)
370            } else {
371                HStack {
372                    Spacer()
373                    ProgressView()
374                    Spacer()
375                }
376            }
377        } header: {
378            Text("Dependencies")
379        } footer: {
380            Text("Checks tell a public registry what this repository depends on.")
381        }
382    }
383
384    private func toggle(
385        _ title: String,
386        _ value: Bool,
387        set: @escaping (Bool) async -> Void
388    ) -> some View {
389        SettingToggle(title: title, serverValue: value, write: set)
390            .disabled(model.working)
391    }
392}
393
394/// Same local-state mirror as SettingToggle, for the approvals count.
395private struct ApprovalsStepper: View {
396
397    let serverValue: Int
398    let write: (Int) async -> Void
399
400    @State private var count = 0
401    @State private var seeded = false
402
403    var body: some View {
404        Stepper("Required approvals: \(count)", value: $count, in: 0...10)
405            .onAppear {
406                if !seeded {
407                    count = serverValue
408                    seeded = true
409                }
410            }
411            .onChange(of: serverValue) { _, newValue in
412                count = newValue
413            }
414            .onChange(of: count) { _, newValue in
415                guard newValue != serverValue else { return }
416                Task { await write(newValue) }
417            }
418    }
419}
420
421/// A Toggle backed by real local state that mirrors the server value.
422/// A computed Binding whose setter spawns a Task proved unreliable under
423/// synthesized taps; a plain @State toggle is not.
424private struct SettingToggle: View {
425
426    let title: String
427    let serverValue: Bool
428    let write: (Bool) async -> Void
429
430    @State private var isOn = false
431    @State private var seeded = false
432
433    var body: some View {
434        Toggle(title, isOn: $isOn)
435            .onAppear {
436                if !seeded {
437                    isOn = serverValue
438                    seeded = true
439                }
440            }
441            .onChange(of: serverValue) { _, newValue in
442                isOn = newValue
443            }
444            .onChange(of: isOn) { _, newValue in
445                guard newValue != serverValue else { return }
446                Task { await write(newValue) }
447            }
448    }
449}