gitbay/Views/Repos/RepoSettingsView.swift
274 lines · 10170 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 Label(error, systemImage: "hand.raised")
24 .foregroundStyle(.orange)
25 .font(.subheadline)
26 }
27 }
28 aboutSection(loaded)
29 topicsSection(loaded)
30 visibilitySection(loaded)
31 branchesSection(loaded)
32 mergeRulesSection(loaded)
33 daemonSection(loaded)
34 }
35 }
36 .overlay { LoadStateOverlay(state: model.state) }
37 .navigationTitle("Settings")
38 .navigationBarTitleDisplayMode(.inline)
39 .task {
40 await model.load()
41 if !loadedOnce, let loaded = model.state.value {
42 descriptionText = loaded.detail.description ?? ""
43 websiteText = loaded.settings.website ?? ""
44 loadedOnce = true
45 }
46 }
47 .refreshable { await model.load() }
48 }
49
50 // MARK: - Sections
51
52 private func aboutSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
53 Section("About") {
54 HStack {
55 TextField("Description", text: $descriptionText, axis: .vertical)
56 .lineLimit(1...3)
57 .accessibilityIdentifier("settings-description")
58 if descriptionText != (loaded.detail.description ?? "") {
59 Button("Save") {
60 Task { await model.setDescription(descriptionText) }
61 }
62 .font(.caption)
63 .disabled(model.working)
64 .accessibilityIdentifier("settings-description-save")
65 }
66 }
67 HStack {
68 TextField("Website", text: $websiteText)
69 .keyboardType(.URL)
70 .autocorrectionDisabled()
71 .textInputAutocapitalization(.never)
72 .accessibilityIdentifier("settings-website")
73 if websiteText != (loaded.settings.website ?? "") {
74 Button("Save") {
75 Task { await model.setWebsite(websiteText) }
76 }
77 .font(.caption)
78 .disabled(model.working)
79 }
80 }
81 }
82 }
83
84 private func topicsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
85 Section("Topics") {
86 if let topics = loaded.detail.topics, !topics.isEmpty {
87 ScrollView(.horizontal, showsIndicators: false) {
88 HStack(spacing: 6) {
89 ForEach(topics, id: \.self) { topic in
90 HStack(spacing: 3) {
91 Text(topic)
92 Button {
93 Task { await model.removeTopic(topic) }
94 } label: {
95 Image(systemName: "xmark.circle.fill")
96 .foregroundStyle(.tertiary)
97 }
98 .disabled(model.working)
99 }
100 .font(.caption)
101 .padding(.horizontal, 8)
102 .padding(.vertical, 3)
103 .background(.quaternary, in: Capsule())
104 }
105 }
106 }
107 }
108 HStack {
109 TextField("Add topic", text: $newTopic)
110 .autocorrectionDisabled()
111 .textInputAutocapitalization(.never)
112 .accessibilityIdentifier("settings-add-topic")
113 Button {
114 let topic = newTopic.trimmingCharacters(in: .whitespaces)
115 newTopic = ""
116 Task { await model.addTopic(topic) }
117 } label: {
118 Image(systemName: "plus.circle.fill")
119 }
120 .disabled(newTopic.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
121 .accessibilityIdentifier("settings-add-topic-submit")
122 }
123 }
124 }
125
126 private func visibilitySection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
127 Section {
128 Picker("Visibility", selection: Binding(
129 get: { loaded.detail.visibility },
130 set: { newValue in Task { await model.setVisibility(newValue) } }
131 )) {
132 Text("Public").tag("public")
133 Text("Private").tag("private")
134 }
135 .disabled(model.working)
136 } footer: {
137 Text("Private repositories are visible only to the owner and granted accounts.")
138 }
139 }
140
141 private func branchesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
142 Section {
143 ForEach(loaded.settings.branches, id: \.self) { branch in
144 HStack {
145 Label(branch, systemImage: "lock.shield")
146 .font(.subheadline)
147 Spacer()
148 Button("Unprotect") {
149 Task { await model.unprotectBranch(branch) }
150 }
151 .font(.caption)
152 .disabled(model.working)
153 }
154 }
155 HStack {
156 TextField("Protect branch", text: $newBranch)
157 .autocorrectionDisabled()
158 .textInputAutocapitalization(.never)
159 Button {
160 let branch = newBranch.trimmingCharacters(in: .whitespaces)
161 newBranch = ""
162 Task { await model.protectBranch(branch) }
163 } label: {
164 Image(systemName: "plus.circle.fill")
165 }
166 .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
167 }
168 } header: {
169 Text("Protected branches")
170 } footer: {
171 Text("Protected branches refuse force pushes and deletion.")
172 }
173 }
174
175 private func mergeRulesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
176 Section {
177 ApprovalsStepper(
178 serverValue: loaded.settings.requireApprovals ?? 0
179 ) { newValue in
180 await model.setRequireApprovals(newValue)
181 }
182 .disabled(model.working)
183 toggle("Require threads resolved", loaded.settings.requireResolved ?? false) {
184 await model.setRequireResolved($0)
185 }
186 toggle("Require green checks", loaded.settings.requireChecks ?? false) {
187 await model.setRequireChecks($0)
188 }
189 toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
190 await model.setRequireSigned($0)
191 }
192 } header: {
193 Text("Merge requirements")
194 } footer: {
195 Text("The server enforces these on every merge, whatever the surface.")
196 }
197 }
198
199 private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
200 Section {
201 toggle("git:// daemon", loaded.settings.gitDaemon ?? false) {
202 await model.setGitDaemon($0)
203 }
204 } footer: {
205 Text("Anonymous, unauthenticated read access over the git protocol.")
206 }
207 }
208
209 private func toggle(
210 _ title: String,
211 _ value: Bool,
212 set: @escaping (Bool) async -> Void
213 ) -> some View {
214 SettingToggle(title: title, serverValue: value, write: set)
215 .disabled(model.working)
216 }
217}
218
219/// Same local-state mirror as SettingToggle, for the approvals count.
220private struct ApprovalsStepper: View {
221
222 let serverValue: Int
223 let write: (Int) async -> Void
224
225 @State private var count = 0
226 @State private var seeded = false
227
228 var body: some View {
229 Stepper("Required approvals: \(count)", value: $count, in: 0...10)
230 .onAppear {
231 if !seeded {
232 count = serverValue
233 seeded = true
234 }
235 }
236 .onChange(of: serverValue) { _, newValue in
237 count = newValue
238 }
239 .onChange(of: count) { _, newValue in
240 guard newValue != serverValue else { return }
241 Task { await write(newValue) }
242 }
243 }
244}
245
246/// A Toggle backed by real local state that mirrors the server value.
247/// A computed Binding whose setter spawns a Task proved unreliable under
248/// synthesized taps; a plain @State toggle is not.
249private struct SettingToggle: View {
250
251 let title: String
252 let serverValue: Bool
253 let write: (Bool) async -> Void
254
255 @State private var isOn = false
256 @State private var seeded = false
257
258 var body: some View {
259 Toggle(title, isOn: $isOn)
260 .onAppear {
261 if !seeded {
262 isOn = serverValue
263 seeded = true
264 }
265 }
266 .onChange(of: serverValue) { _, newValue in
267 isOn = newValue
268 }
269 .onChange(of: isOn) { _, newValue in
270 guard newValue != serverValue else { return }
271 Task { await write(newValue) }
272 }
273 }
274}