gitbay/Views/Repos/RepoSettingsView.swift
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}