import SwiftUI /// Admin knobs, one command per control. A non-admin sees the server's /// refusal instead of a half-working form. struct RepoSettingsView: View { @State private var model: RepoSettingsViewModel @State private var descriptionText = "" @State private var websiteText = "" @State private var newTopic = "" @State private var newBranch = "" @State private var loadedOnce = false init(client: GitbayClient, repo: String) { _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo)) } var body: some View { List { if let loaded = model.state.value { if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } aboutSection(loaded) topicsSection(loaded) visibilitySection(loaded) branchesSection(loaded) mergeRulesSection(loaded) daemonSection(loaded) } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .task { await model.load() if !loadedOnce, let loaded = model.state.value { descriptionText = loaded.detail.description ?? "" websiteText = loaded.settings.website ?? "" loadedOnce = true } } .refreshable { await model.load() } } // MARK: - Sections private func aboutSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { Section("About") { HStack { TextField("Description", text: $descriptionText, axis: .vertical) .lineLimit(1...3) .accessibilityIdentifier("settings-description") if descriptionText != (loaded.detail.description ?? "") { Button("Save") { Task { await model.setDescription(descriptionText) } } .font(.gbSans(.caption)) .disabled(model.working) .accessibilityIdentifier("settings-description-save") } } HStack { TextField("Website", text: $websiteText) .keyboardType(.URL) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("settings-website") if websiteText != (loaded.settings.website ?? "") { Button("Save") { Task { await model.setWebsite(websiteText) } } .font(.gbSans(.caption)) .disabled(model.working) } } } } private func topicsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { Section("Topics") { if let topics = loaded.detail.topics, !topics.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(topics, id: \.self) { topic in HStack(spacing: 3) { Text(topic) Button { Task { await model.removeTopic(topic) } } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .disabled(model.working) } .font(.gbSans(.caption)) .foregroundStyle(Color.gbAccent) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.gbAccent.opacity(0.07), in: gbChipShape) .overlay(gbChipShape.stroke(Color.gbAccent.opacity(0.35), lineWidth: 1)) } } } } HStack { TextField("Add topic", text: $newTopic) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("settings-add-topic") Button { let topic = newTopic.trimmingCharacters(in: .whitespaces) newTopic = "" Task { await model.addTopic(topic) } } label: { Image(systemName: "plus.circle.fill") } .disabled(newTopic.trimmingCharacters(in: .whitespaces).isEmpty || model.working) .accessibilityIdentifier("settings-add-topic-submit") } } } private func visibilitySection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { Section { Picker("Visibility", selection: Binding( get: { loaded.detail.visibility }, set: { newValue in Task { await model.setVisibility(newValue) } } )) { Text("Public").tag("public") Text("Private").tag("private") } .disabled(model.working) } footer: { Text("Private repositories are visible only to the owner and granted accounts.") } } private func branchesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { Section { ForEach(loaded.settings.branches, id: \.self) { branch in HStack { Label(branch, systemImage: "lock.shield") .font(.gbSans(.subheadline)) Spacer() Button("Unprotect") { Task { await model.unprotectBranch(branch) } } .font(.gbSans(.caption)) .disabled(model.working) } } HStack { TextField("Protect branch", text: $newBranch) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button { let branch = newBranch.trimmingCharacters(in: .whitespaces) newBranch = "" Task { await model.protectBranch(branch) } } label: { Image(systemName: "plus.circle.fill") } .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working) } } header: { Text("Protected branches") } footer: { Text("Protected branches refuse force pushes and deletion.") } } private func mergeRulesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { Section { ApprovalsStepper( serverValue: loaded.settings.requireApprovals ?? 0 ) { newValue in await model.setRequireApprovals(newValue) } .disabled(model.working) toggle("Require threads resolved", loaded.settings.requireResolved ?? false) { await model.setRequireResolved($0) } toggle("Require green checks", loaded.settings.requireChecks ?? false) { await model.setRequireChecks($0) } toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) { await model.setRequireSigned($0) } } header: { Text("Merge requirements") } footer: { Text("The server enforces these on every merge, whatever the surface.") } } private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { Section { toggle("git:// daemon", loaded.settings.gitDaemon ?? false) { await model.setGitDaemon($0) } } footer: { Text("Anonymous, unauthenticated read access over the git protocol.") } } private func toggle( _ title: String, _ value: Bool, set: @escaping (Bool) async -> Void ) -> some View { SettingToggle(title: title, serverValue: value, write: set) .disabled(model.working) } } /// Same local-state mirror as SettingToggle, for the approvals count. private struct ApprovalsStepper: View { let serverValue: Int let write: (Int) async -> Void @State private var count = 0 @State private var seeded = false var body: some View { Stepper("Required approvals: \(count)", value: $count, in: 0...10) .onAppear { if !seeded { count = serverValue seeded = true } } .onChange(of: serverValue) { _, newValue in count = newValue } .onChange(of: count) { _, newValue in guard newValue != serverValue else { return } Task { await write(newValue) } } } } /// A Toggle backed by real local state that mirrors the server value. /// A computed Binding whose setter spawns a Task proved unreliable under /// synthesized taps; a plain @State toggle is not. private struct SettingToggle: View { let title: String let serverValue: Bool let write: (Bool) async -> Void @State private var isOn = false @State private var seeded = false var body: some View { Toggle(title, isOn: $isOn) .onAppear { if !seeded { isOn = serverValue seeded = true } } .onChange(of: serverValue) { _, newValue in isOn = newValue } .onChange(of: isOn) { _, newValue in guard newValue != serverValue else { return } Task { await write(newValue) } } } }