gitbay/Views/Repos/RepoSettingsView.swift

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

339 lines · 12958 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
 14    init(client: GitbayClient, repo: String) {
 15        _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo))
 16    }
 17
 18    var body: some View {
 19        List {
 20            if let loaded = model.state.value {
 21                if let error = model.actionError {
 22                    Section {
 23                        GBNotice(error, .gbWarn)
 24                    }
 25                }
 26                aboutSection(loaded)
 27                topicsSection(loaded)
 28                visibilitySection(loaded)
 29                branchesSection(loaded)
 30                mergeRulesSection(loaded)
 31                daemonSection(loaded)
 32                depsSection()
 33            }
 34        }
 35        .overlay { LoadStateOverlay(state: model.state) }
 36        .navigationTitle("Settings")
 37        .navigationBarTitleDisplayMode(.inline)
 38        .task {
 39            await model.load()
 40            if !loadedOnce, let loaded = model.state.value {
 41                descriptionText = loaded.detail.description ?? ""
 42                websiteText = loaded.settings.website ?? ""
 43                loadedOnce = true
 44            }
 45        }
 46        .task { await model.loadDeps() }
 47        .refreshable {
 48            await model.load()
 49            await model.loadDeps()
 50        }
 51    }
 52
 53    // MARK: - Sections
 54
 55    private func aboutSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
 56        Section("About") {
 57            HStack {
 58                TextField("Description", text: $descriptionText, axis: .vertical)
 59                    .lineLimit(1...3)
 60                    .accessibilityIdentifier("settings-description")
 61                if descriptionText != (loaded.detail.description ?? "") {
 62                    Button("Save") {
 63                        Task { await model.setDescription(descriptionText) }
 64                    }
 65                    .font(.gbSans(.caption))
 66                    .disabled(model.working)
 67                    .accessibilityIdentifier("settings-description-save")
 68                }
 69            }
 70            HStack {
 71                TextField("Website", text: $websiteText)
 72                    .keyboardType(.URL)
 73                    .autocorrectionDisabled()
 74                    .textInputAutocapitalization(.never)
 75                    .accessibilityIdentifier("settings-website")
 76                if websiteText != (loaded.settings.website ?? "") {
 77                    Button("Save") {
 78                        Task { await model.setWebsite(websiteText) }
 79                    }
 80                    .font(.gbSans(.caption))
 81                    .disabled(model.working)
 82                }
 83            }
 84        }
 85    }
 86
 87    private func topicsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
 88        Section("Topics") {
 89            if let topics = loaded.detail.topics, !topics.isEmpty {
 90                ScrollView(.horizontal, showsIndicators: false) {
 91                    HStack(spacing: 6) {
 92                        ForEach(topics, id: \.self) { topic in
 93                            HStack(spacing: 3) {
 94                                Text(topic)
 95                                Button {
 96                                    Task { await model.removeTopic(topic) }
 97                                } label: {
 98                                    Image(systemName: "xmark.circle.fill")
 99                                        .foregroundStyle(.tertiary)
100                                }
101                                .disabled(model.working)
102                            }
103                            .font(.gbSans(.caption))
104                            .foregroundStyle(Color.gbAccent)
105                            .padding(.horizontal, 8)
106                            .padding(.vertical, 3)
107                            .background(Color.gbAccent.opacity(0.07), in: gbChipShape)
108                            .overlay(gbChipShape.stroke(Color.gbAccent.opacity(0.35), lineWidth: 1))
109                        }
110                    }
111                }
112            }
113            HStack {
114                TextField("Add topic", text: $newTopic)
115                    .autocorrectionDisabled()
116                    .textInputAutocapitalization(.never)
117                    .accessibilityIdentifier("settings-add-topic")
118                Button {
119                    let topic = newTopic.trimmingCharacters(in: .whitespaces)
120                    newTopic = ""
121                    Task { await model.addTopic(topic) }
122                } label: {
123                    Image(systemName: "plus.circle.fill")
124                }
125                .disabled(newTopic.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
126                .accessibilityIdentifier("settings-add-topic-submit")
127            }
128        }
129    }
130
131    private func visibilitySection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
132        Section {
133            Picker("Visibility", selection: Binding(
134                get: { loaded.detail.visibility },
135                set: { newValue in Task { await model.setVisibility(newValue) } }
136            )) {
137                Text("Public").tag("public")
138                Text("Private").tag("private")
139            }
140            .disabled(model.working)
141        } footer: {
142            Text("Private repositories are visible only to the owner and granted accounts.")
143        }
144    }
145
146    private func branchesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
147        Section {
148            ForEach(loaded.settings.branches, id: \.self) { branch in
149                HStack {
150                    Label(branch, systemImage: "lock.shield")
151                        .font(.gbSans(.subheadline))
152                    Spacer()
153                    Button("Unprotect") {
154                        Task { await model.unprotectBranch(branch) }
155                    }
156                    .font(.gbSans(.caption))
157                    .disabled(model.working)
158                }
159            }
160            HStack {
161                TextField("Protect branch", text: $newBranch)
162                    .autocorrectionDisabled()
163                    .textInputAutocapitalization(.never)
164                Button {
165                    let branch = newBranch.trimmingCharacters(in: .whitespaces)
166                    newBranch = ""
167                    Task { await model.protectBranch(branch) }
168                } label: {
169                    Image(systemName: "plus.circle.fill")
170                }
171                .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
172            }
173        } header: {
174            Text("Protected branches")
175        } footer: {
176            Text("Protected branches refuse force pushes and deletion.")
177        }
178    }
179
180    private func mergeRulesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
181        Section {
182            ApprovalsStepper(
183                serverValue: loaded.settings.requireApprovals ?? 0
184            ) { newValue in
185                await model.setRequireApprovals(newValue)
186            }
187            .disabled(model.working)
188            toggle("Require threads resolved", loaded.settings.requireResolved ?? false) {
189                await model.setRequireResolved($0)
190            }
191            toggle("Require green checks", loaded.settings.requireChecks ?? false) {
192                await model.setRequireChecks($0)
193            }
194            toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
195                await model.setRequireSigned($0)
196            }
197        } header: {
198            Text("Merge requirements")
199        } footer: {
200            Text("The server enforces these on every merge, whatever the surface.")
201        }
202    }
203
204    private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
205        Section {
206            toggle("git:// daemon", loaded.settings.gitDaemon ?? false) {
207                await model.setGitDaemon($0)
208            }
209        } footer: {
210            Text("Anonymous, unauthenticated read access over the git protocol.")
211        }
212    }
213
214    private func depsSection() -> some View {
215        Section {
216            if let deps = model.deps {
217                SettingToggle(title: "Dependency checks", serverValue: deps.enabled) {
218                    await model.setDepsEnabled($0)
219                }
220                .disabled(model.working)
221
222                if deps.enabled {
223                    if let lastCheck = deps.lastCheck {
224                        (Text("Last checked ") + Text(lastCheck, format: .relative(presentation: .named)))
225                            .font(.gbSans(.caption))
226                            .foregroundStyle(.secondary)
227                    } else {
228                        Text("Never checked")
229                            .font(.gbSans(.caption))
230                            .foregroundStyle(.secondary)
231                    }
232                    if let lastError = deps.lastError {
233                        GBNotice(lastError, .gbWarn)
234                    }
235                    if let issueNumber = deps.issueNumber {
236                        NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issueNumber)) {
237                            Text("Tracking issue #\(issueNumber)")
238                        }
239                    }
240                    if deps.behind.isEmpty {
241                        if deps.lastCheck != nil {
242                            Text("Everything is current.")
243                                .font(.gbSans(.caption))
244                                .foregroundStyle(.secondary)
245                        }
246                    } else {
247                        ForEach(deps.behind) { dep in
248                            VStack(alignment: .leading, spacing: 2) {
249                                Text(dep.name)
250                                    .font(.gbSans(.subheadline))
251                                Text("\(dep.ecosystem) · \(dep.current) → \(dep.latest)")
252                                    .font(.gbSans(.caption))
253                                    .foregroundStyle(.secondary)
254                            }
255                        }
256                    }
257                }
258            } else if let depsError = model.depsError {
259                GBNotice(depsError, .gbWarn)
260            } else {
261                HStack {
262                    Spacer()
263                    ProgressView()
264                    Spacer()
265                }
266            }
267        } header: {
268            Text("Dependencies")
269        } footer: {
270            Text("Checks tell a public registry what this repository depends on.")
271        }
272    }
273
274    private func toggle(
275        _ title: String,
276        _ value: Bool,
277        set: @escaping (Bool) async -> Void
278    ) -> some View {
279        SettingToggle(title: title, serverValue: value, write: set)
280            .disabled(model.working)
281    }
282}
283
284/// Same local-state mirror as SettingToggle, for the approvals count.
285private struct ApprovalsStepper: View {
286
287    let serverValue: Int
288    let write: (Int) async -> Void
289
290    @State private var count = 0
291    @State private var seeded = false
292
293    var body: some View {
294        Stepper("Required approvals: \(count)", value: $count, in: 0...10)
295            .onAppear {
296                if !seeded {
297                    count = serverValue
298                    seeded = true
299                }
300            }
301            .onChange(of: serverValue) { _, newValue in
302                count = newValue
303            }
304            .onChange(of: count) { _, newValue in
305                guard newValue != serverValue else { return }
306                Task { await write(newValue) }
307            }
308    }
309}
310
311/// A Toggle backed by real local state that mirrors the server value.
312/// A computed Binding whose setter spawns a Task proved unreliable under
313/// synthesized taps; a plain @State toggle is not.
314private struct SettingToggle: View {
315
316    let title: String
317    let serverValue: Bool
318    let write: (Bool) async -> Void
319
320    @State private var isOn = false
321    @State private var seeded = false
322
323    var body: some View {
324        Toggle(title, isOn: $isOn)
325            .onAppear {
326                if !seeded {
327                    isOn = serverValue
328                    seeded = true
329                }
330            }
331            .onChange(of: serverValue) { _, newValue in
332                isOn = newValue
333            }
334            .onChange(of: isOn) { _, newValue in
335                guard newValue != serverValue else { return }
336                Task { await write(newValue) }
337            }
338    }
339}