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 newTagGlob = "" @State private var newContext = "" @State private var renaming = false @State private var newName = "" @State private var renameConfirm = "" /// Set once a rename succeeds: this screen's repository is gone and /// the renamed one is a value push away. @State private var renamedTo: String? @State private var transferring = false @State private var newOwner = "" @State private var transferConfirm = "" @State private var deleting = false @State private var deleteConfirm = "" /// Set once the repository is deleted: nothing on this screen applies. @State private var deleted = false /// A refused rename, transfer or delete, shown where the user is: the /// screen's banner sits at the top of a long list, out of sight. @State private var refusal: String? @State private var loadedOnce = false @State private var removingTopic: String? @State private var newGrantUser = "" @State private var newGrantRole = "read" @State private var revokingGrant: AccessGrant? init(client: GitbayClient, repo: String) { _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo)) } var body: some View { List { if let renamedTo { Section { NavigationLink(value: RepoRoute.repo(renamedTo)) { Label("Open \(renamedTo)", systemImage: "arrow.right.circle") .font(.gbSans(.subheadline)) } .accessibilityIdentifier("settings-renamed-link") } header: { Text("Moved") } footer: { Text("This screen's repository moved; nothing here applies to it any more.") } } if deleted { Section { Label("\(model.repoPath) is deleted", systemImage: "trash") .font(.gbSans(.subheadline)) .accessibilityIdentifier("settings-deleted") } footer: { Text("Its issues, merge requests, releases and builds went with it.") } } if !deleted, renamedTo == nil, let loaded = model.state.value { if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } aboutSection(loaded) topicsSection(loaded) visibilitySection(loaded) defaultBranchSection(loaded) branchesSection(loaded) tagsSection(loaded) mergeRulesSection(loaded) daemonSection(loaded) accessSection webhooksSection depsSection() renameSection transferSection deleteSection } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .task { // A screen returned to after its repository moved or went has // nothing to load; the notice is all it shows. guard !deleted, renamedTo == nil else { return } await model.load() if !loadedOnce, let loaded = model.state.value { descriptionText = loaded.detail.description ?? "" websiteText = loaded.settings.website ?? "" loadedOnce = true } } .task { await model.loadDeps() } .task { await model.loadGrants() } .task { await model.loadBranches() } .refreshable { // Nothing to reload once the repository is gone or moved. guard !deleted, renamedTo == nil else { return } await model.load() await model.loadDeps() await model.loadGrants() await model.loadBranches() } .confirmationDialog( "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?", isPresented: Binding( get: { revokingGrant != nil }, set: { if !$0 { revokingGrant = nil } } ) ) { Button("Revoke", role: .destructive) { if let grant = revokingGrant { Task { await model.revoke(user: grant.user) } } revokingGrant = nil } Button("Cancel", role: .cancel) {} } .alert("Rename \(model.repoPath)", isPresented: $renaming) { TextField("New name", text: $newName) .autocorrectionDisabled() .textInputAutocapitalization(.never) TextField("type \(model.repoPath) to confirm", text: $renameConfirm) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Rename") { Task { if let path = await model.rename(to: newName) { renamedTo = path } else { refusal = model.actionError } } } .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty || renameConfirm != model.repoPath) Button("Cancel", role: .cancel) {} } message: { Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.") } .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) { Button("OK", role: .cancel) {} } message: { Text(refusal ?? "") } .alert("Transfer \(model.repoPath)", isPresented: $transferring) { TextField("New owner", text: $newOwner) .autocorrectionDisabled() .textInputAutocapitalization(.never) TextField("type \(model.repoPath) to confirm", text: $transferConfirm) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Transfer") { Task { if let path = await model.transfer(to: newOwner) { renamedTo = path } else { refusal = model.actionError } } } .disabled(newOwner.trimmingCharacters(in: .whitespaces).isEmpty || transferConfirm != model.repoPath) Button("Cancel", role: .cancel) {} } message: { Text("The repository moves to another account or organization, and its clone URLs change. Type \(model.repoPath) to confirm.") } .alert("Delete \(model.repoPath)", isPresented: $deleting) { TextField("type \(model.repoPath) to confirm", text: $deleteConfirm) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Delete", role: .destructive) { Task { if await model.delete() { deleted = true } else { refusal = model.actionError } } } .disabled(deleteConfirm != model.repoPath) Button("Cancel", role: .cancel) {} } message: { Text("This deletes the repository with its issues, merge requests, releases and builds, and cannot be undone. Type \(model.repoPath) to confirm.") } .confirmationDialog( "Remove topic \(removingTopic ?? "")?", isPresented: Binding( get: { removingTopic != nil }, set: { if !$0 { removingTopic = nil } } ) ) { Button("Remove", role: .destructive) { if let topic = removingTopic { Task { await model.removeTopic(topic) } } removingTopic = nil } Button("Cancel", role: .cancel) {} } } // 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 { removingTopic = topic } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .accessibilityLabel("Remove \(topic)") .disabled(model.working) .accessibilityIdentifier("settings-remove-topic-\(topic)") } .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") } .accessibilityLabel("Add topic") .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") } .accessibilityLabel("Protect branch") .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working) } toggle("Merge requests only", loaded.settings.requireMR ?? false) { await model.setRequireMR($0) } } header: { Text("Protected branches") } footer: { Text("Protected branches refuse force pushes and deletion. Merge requests only also refuses direct pushes to them; merging a merge request becomes the only way to change them.") } } /// `repo settings default-branch`: a menu over the branches the /// server listed, since it refuses a name that does not exist. private func defaultBranchSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { Section { Menu { ForEach(model.branches, id: \.self) { branch in Button(branch) { if branch != loaded.detail.defaultBranch { Task { await model.setDefaultBranch(branch) } } } } } label: { HStack { Label(loaded.detail.defaultBranch, systemImage: "arrow.triangle.branch") .font(.gbSans(.subheadline)) Spacer() Image(systemName: "chevron.up.chevron.down") .font(.gbSans(.caption)) .foregroundStyle(.secondary) } } .disabled(model.working || model.branches.isEmpty) .accessibilityIdentifier("settings-default-branch-menu") } header: { Text("Default branch") } footer: { Text("What a clone checks out, and what merge requests target by default.") } } /// `repo settings protect-tag` / `unprotect-tag`: globs, path.Match style. private func tagsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { Section { ForEach(loaded.settings.tags, id: \.self) { glob in HStack { Label(glob, systemImage: "tag") .font(.gbMono(.caption)) Spacer() Button("Unprotect") { Task { await model.unprotectTag(glob) } } .font(.gbSans(.caption)) .disabled(model.working) .accessibilityIdentifier("settings-unprotect-tag-\(glob)") } } HStack { TextField("Protect tags matching", text: $newTagGlob) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("settings-protect-tag") Button { let glob = newTagGlob.trimmingCharacters(in: .whitespaces) newTagGlob = "" Task { await model.protectTag(glob) } } label: { Image(systemName: "plus.circle.fill") } .accessibilityLabel("Protect tag") .disabled(newTagGlob.trimmingCharacters(in: .whitespaces).isEmpty || model.working) .accessibilityIdentifier("settings-protect-tag-submit") } } header: { Text("Protected tags") } footer: { Text("A matching tag is created once and then refuses moves and deletion. Globs such as v*.") } } 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) } VStack(alignment: .leading, spacing: 6) { Text(contextsHeading(loaded.settings)) .font(.gbSans(.subheadline)) RemovableChips(items: loaded.settings.contexts, idPrefix: "settings-context", disabled: model.working, remove: { context in Task { await model.removeRequiredContext(context) } }) HStack { TextField("ci/test", text: $newContext) .font(.gbMono(.subheadline)) .autocorrectionDisabled() .textInputAutocapitalization(.never) .onSubmit { if !model.working { addContext() } } .accessibilityIdentifier("settings-context-field") Button { addContext() } label: { Image(systemName: "plus.circle.fill") } .accessibilityLabel("Require check") .disabled(newContext.trimmingCharacters(in: .whitespaces).isEmpty || model.working) .accessibilityIdentifier("settings-context-add") } } toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) { await model.setRequireSigned($0) } toggle("Require code owner review", loaded.settings.requireCodeowners ?? false) { await model.setRequireCodeowners($0) } } header: { Text("Merge requirements") } footer: { Text("The server enforces these on every merge, whatever the surface. Naming a check turns the checks gate on.") } } private func addContext() { let context = newContext Task { if await model.addRequiredContext(context), newContext == context { newContext = "" } } } private func contextsHeading(_ settings: RepoSettings) -> String { if !(settings.requireChecks ?? false) { return settings.contexts.isEmpty ? "Name a check to require it" : "Named checks, not required while the gate is off" } return settings.contexts.isEmpty ? "Every check on the head must pass" : "These checks must pass" } 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.") } } /// `repo access list`, grant and revoke. Owners and org members are /// not grants and do not appear here. private var accessSection: some View { Section { if let error = model.grantsError { GBNotice(error, .gbWarn) } ForEach(model.grants ?? []) { grant in HStack { NavigationLink(value: RepoRoute.profile(grant.user)) { Text(grant.user) .font(.gbSans(.subheadline)) } Spacer() if let source = grant.source, source != "direct" { GBChip(source, .secondary) } Menu { ForEach(["read", "write", "admin"], id: \.self) { role in Button(role) { Task { await model.grant(user: grant.user, role: role) } } } } label: { GBChip(grant.role, .secondary) } .disabled(model.working) } .swipeActions { if grant.isDirect { Button("Revoke", role: .destructive) { revokingGrant = grant } } } } VStack(spacing: 8) { TextField("Grant user", text: $newGrantUser) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("settings-grant-user") HStack { Picker("Role", selection: $newGrantRole) { Text("read").tag("read") Text("write").tag("write") Text("admin").tag("admin") } .pickerStyle(.segmented) Button("Grant") { let user = newGrantUser newGrantUser = "" Task { await model.grant(user: user, role: newGrantRole) } } .buttonStyle(.borderless) .disabled(newGrantUser.trimmingCharacters(in: .whitespaces).isEmpty || model.working) .accessibilityIdentifier("settings-grant-submit") } } } header: { Text("Access") } footer: { Text("Everyone who can reach this repository and how. Only a direct grant can be revoked here; a role change grants directly.") } } private var webhooksSection: some View { Section { NavigationLink(value: RepoRoute.webhooks(repo: model.repoPath)) { Label("Webhooks", systemImage: "antenna.radiowaves.left.and.right") .font(.gbSans(.subheadline)) } .accessibilityIdentifier("settings-webhooks-link") NavigationLink(value: RepoRoute.buildSecrets(repo: model.repoPath)) { Label("Build secrets", systemImage: "key.horizontal") .font(.gbSans(.subheadline)) } .accessibilityIdentifier("settings-secrets-link") NavigationLink(value: RepoRoute.deployKeys(repo: model.repoPath)) { Label("Deploy keys", systemImage: "key") .font(.gbSans(.subheadline)) } .accessibilityIdentifier("settings-deploy-keys-link") NavigationLink(value: RepoRoute.mirrors(repo: model.repoPath)) { Label("Mirrors", systemImage: "arrow.triangle.branch") .font(.gbSans(.subheadline)) } .accessibilityIdentifier("settings-mirrors-link") NavigationLink(value: RepoRoute.pageDomains(repo: model.repoPath)) { Label("Custom domains", systemImage: "globe") .font(.gbSans(.subheadline)) } .accessibilityIdentifier("settings-domains-link") } } /// `repo rename`. Delete and transfer stay SSH-only by design; a /// rename keeps the owner and is routine for someone with many repos. private var renameSection: some View { Section { Button { newName = String(model.repoPath.split(separator: "/").last ?? "") renameConfirm = "" renaming = true } label: { Label("Rename repository", systemImage: "pencil") .font(.gbSans(.subheadline)) } .disabled(model.working) .accessibilityIdentifier("settings-rename-button") } footer: { Text("The owner stays; the name changes, and so do the clone URLs.") } } private var transferSection: some View { Section { Button { newOwner = "" transferConfirm = "" transferring = true } label: { Label("Transfer repository", systemImage: "arrow.left.arrow.right") .font(.gbSans(.subheadline)) } .disabled(model.working) .accessibilityIdentifier("settings-transfer-button") } footer: { Text("To another account or an organization you can create repositories in.") } } private var deleteSection: some View { Section { Button(role: .destructive) { deleteConfirm = "" deleting = true } label: { Label("Delete repository", systemImage: "trash") .font(.gbSans(.subheadline)) } .disabled(model.working) .accessibilityIdentifier("settings-delete-button") } footer: { Text("Deletes it with everything in it. Only its owner or an admin can.") } } private func depsSection() -> some View { Section { if let deps = model.deps { SettingToggle(title: "Dependency checks", serverValue: deps.enabled) { await model.setDepsEnabled($0) } .disabled(model.working) if deps.enabled { if let lastCheck = deps.lastCheck { (Text("Last checked ") + Text(lastCheck, format: .relative(presentation: .named))) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } else { Text("Never checked") .font(.gbSans(.caption)) .foregroundStyle(.secondary) } if let lastError = deps.lastError { GBNotice(lastError, .gbWarn) } if let issueNumber = deps.issueNumber { NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issueNumber)) { Text("Tracking issue #\(issueNumber)") } } if deps.behind.isEmpty { if deps.lastCheck != nil { Text("Everything is current.") .font(.gbSans(.caption)) .foregroundStyle(.secondary) } } else { ForEach(deps.behind) { dep in VStack(alignment: .leading, spacing: 2) { Text(dep.name) .font(.gbSans(.subheadline)) Text("\(dep.ecosystem) · \(dep.current) → \(dep.latest)") .font(.gbSans(.caption)) .foregroundStyle(.secondary) } } } } } else if let depsError = model.depsError { GBNotice(depsError, .gbWarn) } else { HStack { Spacer() ProgressView() Spacer() } } } header: { Text("Dependencies") } footer: { Text("Checks tell a public registry what this repository depends on.") } } 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. /// /// A write that says whether the server took it lets the switch go back /// when it did not, rather than showing what was refused. A write that /// says nothing is taken as accepted, and the reload decides. struct SettingToggle: View { let title: String let serverValue: Bool let write: (Bool) async -> Bool @State private var isOn = false @State private var seeded = false init(title: String, serverValue: Bool, write: @escaping (Bool) async -> Void) { self.title = title self.serverValue = serverValue self.write = { await write($0); return true } } init(title: String, serverValue: Bool, accepted write: @escaping (Bool) async -> Bool) { self.title = title self.serverValue = serverValue self.write = write } 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 { if !(await write(newValue)) { isOn = !newValue } } } } }