gitbay/Repos/RepoSettingsViewModel.swift
135 lines · 4642 bytes
1import Foundation
2import Observation
3
4/// The repo settings screen: `repo settings show` for the admin knobs,
5/// `repo show` for description/topics/visibility, and one write command
6/// per control. Non-admins get the server's refusal verbatim.
7@Observable
8@MainActor
9final class RepoSettingsViewModel {
10
11 nonisolated struct Loaded: Sendable, Hashable {
12 let settings: RepoSettings
13 let detail: RepoDetail
14 }
15
16 private(set) var state: LoadState<Loaded> = .loading
17 private(set) var actionError: String?
18 private(set) var working = false
19
20 /// `repo deps status`, exposed as an optional plus a separate error
21 /// string rather than folded into one state: `nil` with no error means
22 /// "not loaded yet", `nil` with an error means "the read failed". The
23 /// settings screen must never render "checks are off" when it means
24 /// "we could not ask".
25 private(set) var deps: DepsStatus?
26 private(set) var depsError: String?
27
28 private let client: GitbayClient
29 let repoPath: String
30
31 init(client: GitbayClient, repoPath: String) {
32 self.client = client
33 self.repoPath = repoPath
34 }
35
36 func load() async {
37 do {
38 async let settings = client.read(
39 ["repo", "settings", "show", repoPath], as: RepoSettings.self)
40 async let detail = client.read(["repo", "show", repoPath], as: RepoDetail.self)
41 state = .loaded(Loaded(settings: try await settings, detail: try await detail))
42 } catch {
43 state = .from(error)
44 }
45 }
46
47 /// `repo deps status` — three words, unlike every other `repo` command
48 /// in this file, which is exactly why it's easy to get wrong.
49 func loadDeps() async {
50 depsError = nil
51 deps = nil
52 do {
53 deps = try await client.read(["repo", "deps", "status", repoPath], as: DepsStatus.self)
54 } catch let error as GitbayError {
55 depsError = error.userFacingMessage
56 } catch {
57 depsError = GitbayError.transport(error).userFacingMessage
58 }
59 }
60
61 /// `enable`/`disable` are two distinct commands, not one command with a
62 /// flag. Reloads the status afterward so the report reflects the
63 /// change; a refused write leaves `deps` untouched.
64 func setDepsEnabled(_ on: Bool) async {
65 await perform(["repo", "deps", on ? "enable" : "disable", repoPath], reload: loadDeps)
66 }
67
68 // MARK: - Writes, one command per knob
69
70 func setDescription(_ text: String) async {
71 await perform(["repo", "settings", "description", repoPath, text])
72 }
73
74 func setWebsite(_ url: String) async {
75 await perform(["repo", "settings", "website", repoPath, url])
76 }
77
78 func setVisibility(_ visibility: String) async {
79 await perform(["repo", "settings", "visibility", repoPath, visibility])
80 }
81
82 func setGitDaemon(_ on: Bool) async {
83 await perform(["repo", "settings", "git-daemon", repoPath, on ? "on" : "off"])
84 }
85
86 func protectBranch(_ branch: String) async {
87 await perform(["repo", "settings", "protect", repoPath, branch])
88 }
89
90 func unprotectBranch(_ branch: String) async {
91 await perform(["repo", "settings", "unprotect", repoPath, branch])
92 }
93
94 func setRequireApprovals(_ count: Int) async {
95 await perform(["repo", "settings", "require-approvals", repoPath, String(count)])
96 }
97
98 func setRequireResolved(_ on: Bool) async {
99 await perform(["repo", "settings", "require-resolved", repoPath, on ? "on" : "off"])
100 }
101
102 func setRequireChecks(_ on: Bool) async {
103 await perform(["repo", "settings", "require-checks", repoPath, on ? "on" : "off"])
104 }
105
106 func setRequireSigned(_ on: Bool) async {
107 await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"])
108 }
109
110 func addTopic(_ topic: String) async {
111 await perform(["repo", "topics", "add", repoPath, topic])
112 }
113
114 func removeTopic(_ topic: String) async {
115 await perform(["repo", "topics", "remove", repoPath, topic])
116 }
117
118 private func perform(_ argv: [String]) async {
119 await perform(argv, reload: load)
120 }
121
122 private func perform(_ argv: [String], reload: () async -> Void) async {
123 working = true
124 actionError = nil
125 defer { working = false }
126 do {
127 try await client.run(argv)
128 await reload()
129 } catch let error as GitbayError {
130 actionError = error.userFacingMessage
131 } catch {
132 actionError = GitbayError.transport(error).userFacingMessage
133 }
134 }
135}