import Foundation import Observation /// The repo settings screen: `repo settings show` for the admin knobs, /// `repo show` for description/topics/visibility, and one write command /// per control. Non-admins get the server's refusal verbatim. @Observable @MainActor final class RepoSettingsViewModel { nonisolated struct Loaded: Sendable, Hashable { let settings: RepoSettings let detail: RepoDetail } private(set) var state: LoadState = .loading private(set) var actionError: String? private(set) var working = false /// `repo deps status`, exposed as an optional plus a separate error /// string rather than folded into one state: `nil` with no error means /// "not loaded yet", `nil` with an error means "the read failed". The /// settings screen must never render "checks are off" when it means /// "we could not ask". private(set) var deps: DepsStatus? private(set) var depsError: String? /// `repo access list`, with the same nil-plus-error shape as `deps`: /// "not loaded" and "could not ask" must not read as "nobody granted". private(set) var grants: [AccessGrant]? private(set) var grantsError: String? /// Branch names from `repo refs`, for the default-branch picker. private(set) var branches: [String] = [] private let client: GitbayClient let repoPath: String init(client: GitbayClient, repoPath: String) { self.client = client self.repoPath = repoPath } func load() async { do { async let settings = client.read( ["repo", "settings", "show", repoPath], as: RepoSettings.self) async let detail = client.read(["repo", "show", repoPath], as: RepoDetail.self) state = .loaded(Loaded(settings: try await settings, detail: try await detail)) } catch { state = .from(error) } } /// `repo deps status` — three words, unlike every other `repo` command /// in this file, which is exactly why it's easy to get wrong. func loadDeps() async { depsError = nil deps = nil do { deps = try await client.read(["repo", "deps", "status", repoPath], as: DepsStatus.self) } catch let error as GitbayError { depsError = error.userFacingMessage } catch { depsError = GitbayError.transport(error).userFacingMessage } } /// `enable`/`disable` are two distinct commands, not one command with a /// flag. Reloads the status afterward so the report reflects the /// change; a refused write leaves `deps` untouched. func setDepsEnabled(_ on: Bool) async { await perform(["repo", "deps", on ? "enable" : "disable", repoPath], reload: loadDeps) } // MARK: - Writes, one command per knob func setDescription(_ text: String) async { await perform(["repo", "settings", "description", repoPath, text]) } func setWebsite(_ url: String) async { await perform(["repo", "settings", "website", repoPath, url]) } func setVisibility(_ visibility: String) async { await perform(["repo", "settings", "visibility", repoPath, visibility]) } func setGitDaemon(_ on: Bool) async { await perform(["repo", "settings", "git-daemon", repoPath, on ? "on" : "off"]) } func protectBranch(_ branch: String) async { await perform(["repo", "settings", "protect", repoPath, branch]) } func unprotectBranch(_ branch: String) async { await perform(["repo", "settings", "unprotect", repoPath, branch]) } func setRequireApprovals(_ count: Int) async { await perform(["repo", "settings", "require-approvals", repoPath, String(count)]) } func setRequireResolved(_ on: Bool) async { await perform(["repo", "settings", "require-resolved", repoPath, on ? "on" : "off"]) } func setRequireChecks(_ on: Bool) async { await perform(["repo", "settings", "require-checks", repoPath, on ? "on" : "off"]) } /// `repo settings require-contexts` replaces the list, so the whole /// list goes each time. Naming one turns the checks gate on. /// Returns whether the field can be cleared: the server took it, or /// it was already listed. A refused name stays in the field to fix. @discardableResult func addRequiredContext(_ context: String) async -> Bool { let context = context.trimmingCharacters(in: .whitespaces) let current = state.value?.settings.contexts ?? [] guard !context.isEmpty, !working else { return false } guard !current.contains(context) else { return true } await setRequiredContexts(current + [context]) return actionError == nil } /// Removing the last one leaves the gate as it was, waiting on every check. func removeRequiredContext(_ context: String) async { await setRequiredContexts((state.value?.settings.contexts ?? []).filter { $0 != context }) } /// A check's name is typed, so the server's refusal of one is shown. private func setRequiredContexts(_ contexts: [String]) async { await perform(["repo", "settings", "require-contexts", repoPath] + contexts, typed: true) { await load() } } func setRequireSigned(_ on: Bool) async { await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"]) } func setRequireCodeowners(_ on: Bool) async { await perform(["repo", "settings", "require-codeowners", repoPath, on ? "on" : "off"]) } func setRequireMR(_ on: Bool) async { await perform(["repo", "settings", "require-mr", repoPath, on ? "on" : "off"]) } func protectTag(_ glob: String) async { await perform(["repo", "settings", "protect-tag", repoPath, glob]) } func unprotectTag(_ glob: String) async { await perform(["repo", "settings", "unprotect-tag", repoPath, glob]) } /// `repo settings default-branch`: moves HEAD and the record. The /// server refuses a branch that does not exist, so the picker only /// offers what `repo refs` listed. func setDefaultBranch(_ branch: String) async { await perform(["repo", "settings", "default-branch", repoPath, branch]) } nonisolated private struct Renamed: Decodable, Sendable { let repo: String } /// `repo rename `: the row and the directory /// move together and clone URLs change. Returns the new path; the /// caller navigates there, since this screen's path is now gone. func rename(to name: String) async -> String? { let name = name.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return nil } working = true actionError = nil defer { working = false } do { let renamed = try await client.run(["repo", "rename", repoPath, name], as: Renamed.self) NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath) return renamed?.repo } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } return nil } nonisolated private struct Transferred: Decodable, Sendable { let repo: String } /// `repo transfer `: the repository moves to /// another account or organization and its clone URLs change. Returns /// the new path; this screen's is gone. func transfer(to owner: String) async -> String? { let owner = owner.trimmingCharacters(in: .whitespaces) guard !owner.isEmpty else { return nil } working = true actionError = nil defer { working = false } do { let moved = try await client.run(["repo", "transfer", repoPath, owner], as: Transferred.self) NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath) return moved?.repo } catch let error as GitbayError { // The owner is typed: a refusal of it says what to fix. actionError = error.messageForTypedInput } catch { actionError = GitbayError.transport(error).userFacingMessage } return nil } /// `repo delete --yes`. The screen asks for the path to /// be typed first, as the web does; --yes is that confirmation. func delete() async -> Bool { working = true actionError = nil defer { working = false } do { try await client.run(["repo", "delete", repoPath, "--yes"]) NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath) return true } catch GitbayError.notFound { // The client retries a failure once. A delete that removed the // row and then failed cleaning up answers the retry with not // found: the repository is gone either way. NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath) return true } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } return false } func loadBranches() async { let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self) branches = refs?.branches.map(\.name) ?? [] } func loadGrants() async { grantsError = nil grants = nil do { grants = try await client.readList(["repo", "access", "list", repoPath], of: AccessGrant.self) } catch let error as GitbayError { grantsError = error.userFacingMessage } catch { grantsError = GitbayError.transport(error).userFacingMessage } } /// `repo access grant read|write|admin` — granting again /// changes an existing user's role. func grant(user: String, role: String) async { let user = user.trimmingCharacters(in: .whitespaces) guard !user.isEmpty else { return } await perform(["repo", "access", "grant", repoPath, user, role], reload: loadGrants) } func revoke(user: String) async { await perform(["repo", "access", "revoke", repoPath, user], reload: loadGrants) } func addTopic(_ topic: String) async { await perform(["repo", "topics", "add", repoPath, topic]) } func removeTopic(_ topic: String) async { await perform(["repo", "topics", "remove", repoPath, topic]) } private func perform(_ argv: [String]) async { await perform(argv, reload: load) } private func perform(_ argv: [String], typed: Bool = false, reload: () async -> Void) async { working = true actionError = nil defer { working = false } do { try await client.run(argv) await reload() } catch let error as GitbayError { actionError = typed ? error.messageForTypedInput : error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } }