krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.5.1: Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift · raw
1import Foundation
2
3private struct HgUpdateRepositoryResponse: Decodable, Sendable {
4 let updateRepository: HgUpdatedRepository
5}
6
7private struct HgUpdatedRepository: Decodable, Sendable {
8 let id: Int
9}
10
11private struct HgRepositoryInfoResponse: Decodable, Sendable {
12 let repository: HgRepositoryInfo?
13}
14
15private struct HgRepositoryInfo: Decodable, Sendable {
16 let description: String?
17 let visibility: Visibility
18 let nonPublishing: Bool?
19}
20
21private struct HgACLResponse: Decodable, Sendable {
22 let repository: HgACLRepository?
23}
24
25private struct HgACLRepository: Decodable, Sendable {
26 let accessControlList: HgACLPage
27}
28
29private struct HgACLPage: Decodable, Sendable {
30 let results: [HgACLEntry]
31 let cursor: String?
32}
33
34private struct HgUpdateACLResponse: Decodable, Sendable {
35 let updateACL: HgACLEntry
36}
37
38private struct HgDeleteACLResponse: Decodable, Sendable {
39 let deleteACL: HgDeletedACL
40}
41
42private struct HgDeletedACL: Decodable, Sendable {
43 let id: Int
44}
45
46private struct HgDeleteRepositoryResponse: Decodable, Sendable {
47 let deleteRepository: HgDeletedRepository
48}
49
50private struct HgDeletedRepository: Decodable, Sendable {
51 let id: Int
52}
53
54struct HgACLEntry: Decodable, Sendable, Identifiable {
55 let id: Int
56 let mode: String
57 let entity: Entity
58}
59
60@Observable
61@MainActor
62final class HgRepositorySettingsViewModel {
63 let repositoryId: Int
64 let repositoryRid: String
65 let repositoryName: String
66 private let client: SRHTClient
67
68 var editedDescription: String
69 var editedVisibility: Visibility
70 var editedNonPublishing: Bool
71 var isSavingInfo = false
72
73 private(set) var acls: [HgACLEntry] = []
74 private(set) var isLoadingACLs = false
75 var newACLEntity = ""
76 var newACLMode = "RO"
77 var isAddingACL = false
78 var isDeletingACL = false
79
80 var histeditRevision = ""
81 var isDeleting = false
82 var didDelete = false
83 var error: String?
84
85 init(repository: RepositorySummary, client: SRHTClient) {
86 self.repositoryId = repository.id
87 self.repositoryRid = repository.rid
88 self.repositoryName = repository.name
89 self.client = client
90 self.editedDescription = repository.description ?? ""
91 self.editedVisibility = repository.visibility
92 self.editedNonPublishing = false
93 }
94
95 private static let updateRepositoryMutation = """
96 mutation updateRepository($id: Int!, $input: RepoInput!) {
97 updateRepository(id: $id, input: $input) {
98 id
99 }
100 }
101 """
102
103 private static let accessControlListQuery = """
104 query hgAccessControlList($rid: ID!) {
105 repository(rid: $rid) {
106 accessControlList {
107 results {
108 id
109 mode
110 entity { canonicalName }
111 }
112 cursor
113 }
114 }
115 }
116 """
117
118 private static let updateACLMutation = """
119 mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
120 updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
121 id
122 mode
123 entity { canonicalName }
124 }
125 }
126 """
127
128 private static let deleteACLMutation = """
129 mutation deleteACL($id: Int!) {
130 deleteACL(id: $id) { id }
131 }
132 """
133
134 private static let deleteRepositoryMutation = """
135 mutation deleteRepository($id: Int!) {
136 deleteRepository(id: $id) { id }
137 }
138 """
139
140 private static let repositoryInfoQuery = """
141 query hgRepositoryInfo($rid: ID!) {
142 repository(rid: $rid) {
143 description
144 visibility
145 nonPublishing
146 }
147 }
148 """
149
150 func loadRepositoryInfo() async {
151 error = nil
152
153 do {
154 let result = try await client.execute(
155 service: .hg,
156 query: Self.repositoryInfoQuery,
157 variables: ["rid": repositoryRid],
158 responseType: HgRepositoryInfoResponse.self
159 )
160
161 if let repository = result.repository {
162 editedDescription = repository.description ?? ""
163 editedVisibility = repository.visibility
164 editedNonPublishing = repository.nonPublishing ?? false
165 }
166 } catch {
167 self.error = error.userFacingMessage
168 }
169 }
170
171 func saveInfo() async -> Bool {
172 isSavingInfo = true
173 defer { isSavingInfo = false }
174 error = nil
175
176 do {
177 let input: [String: any Sendable] = [
178 "description": editedDescription,
179 "visibility": editedVisibility.rawValue,
180 "nonPublishing": editedNonPublishing
181 ]
182 _ = try await client.execute(
183 service: .hg,
184 query: Self.updateRepositoryMutation,
185 variables: ["id": repositoryId, "input": input],
186 responseType: HgUpdateRepositoryResponse.self
187 )
188 return true
189 } catch {
190 self.error = error.userFacingMessage
191 return false
192 }
193 }
194
195 func loadACLs() async {
196 guard !isLoadingACLs else { return }
197 isLoadingACLs = true
198 defer { isLoadingACLs = false }
199 error = nil
200
201 do {
202 let result = try await client.execute(
203 service: .hg,
204 query: Self.accessControlListQuery,
205 variables: ["rid": repositoryRid],
206 responseType: HgACLResponse.self
207 )
208 acls = result.repository?.accessControlList.results ?? []
209 } catch {
210 self.error = error.userFacingMessage
211 }
212 }
213
214 func addACL() async {
215 let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
216 guard !rawEntity.isEmpty else { return }
217 let entity = hgCanonicalEntity(from: rawEntity)
218 isAddingACL = true
219 defer { isAddingACL = false }
220 error = nil
221
222 do {
223 let result = try await client.execute(
224 service: .hg,
225 query: Self.updateACLMutation,
226 variables: [
227 "repoId": repositoryId,
228 "mode": newACLMode,
229 "entity": entity
230 ],
231 responseType: HgUpdateACLResponse.self
232 )
233 if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
234 acls[index] = result.updateACL
235 } else {
236 acls.append(result.updateACL)
237 }
238 newACLEntity = ""
239 } catch {
240 let message = error.localizedDescription
241 if message.localizedCaseInsensitiveContains("No such repository or user found") {
242 self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first."
243 } else {
244 self.error = error.userFacingMessage
245 }
246 }
247 }
248
249 private func hgCanonicalEntity(from input: String) -> String {
250 let username = input.hasPrefix("~") ? String(input.dropFirst()) : input
251 return "~\(username)"
252 }
253
254 func deleteACL(_ entry: HgACLEntry) async {
255 isDeletingACL = true
256 defer { isDeletingACL = false }
257 error = nil
258
259 do {
260 _ = try await client.execute(
261 service: .hg,
262 query: Self.deleteACLMutation,
263 variables: ["id": entry.id],
264 responseType: HgDeleteACLResponse.self
265 )
266 acls.removeAll { $0.id == entry.id }
267 } catch {
268 self.error = error.userFacingMessage
269 }
270 }
271
272 func deleteRepository() async {
273 isDeleting = true
274 defer { isDeleting = false }
275 error = nil
276
277 do {
278 _ = try await client.execute(
279 service: .hg,
280 query: Self.deleteRepositoryMutation,
281 variables: ["id": repositoryId],
282 responseType: HgDeleteRepositoryResponse.self
283 )
284 didDelete = true
285 } catch {
286 self.error = error.userFacingMessage
287 }
288 }
289}