gitbay/Views/Repos/RepoSettingsView.swift

5411753023a5e2958f17b523903abce380941107
gitbay-ios/gitbay/Views/Repos/RepoSettingsView.swift history · blame · raw

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}