krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: 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 private var initialDescription: String
68 private var initialVisibility: Visibility
69
70 var editedDescription: String
71 var editedVisibility: Visibility
72 var editedNonPublishing: Bool
73 var isSavingInfo = false
74 private(set) var loadedNonPublishing = false
75
76 private(set) var acls: [HgACLEntry] = []
77 private(set) var isLoadingACLs = false
78 var newACLEntity = ""
79 var newACLMode = "RO"
80 var isAddingACL = false
81 var isDeletingACL = false
82
83 var histeditRevision = ""
84 var isDeleting = false
85 var didDelete = false
86 var error: String?
87
88 var normalizedEditedDescription: String {
89 editedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
90 }
91
92 var isInfoDirty: Bool {
93 normalizedEditedDescription != initialDescription ||
94 editedVisibility != initialVisibility ||
95 editedNonPublishing != loadedNonPublishing
96 }
97
98 init(repository: RepositorySummary, client: SRHTClient) {
99 self.repositoryId = repository.id
100 self.repositoryRid = repository.rid
101 self.repositoryName = repository.name
102 self.client = client
103 let description = repository.description ?? ""
104 self.initialDescription = description
105 self.initialVisibility = repository.visibility
106 self.editedDescription = description
107 self.editedVisibility = repository.visibility
108 self.editedNonPublishing = false
109 }
110
111 private static let updateRepositoryMutation = """
112 mutation updateRepository($id: Int!, $input: RepoInput!) {
113 updateRepository(id: $id, input: $input) {
114 id
115 }
116 }
117 """
118
119 private static let accessControlListQuery = """
120 query hgAccessControlList($rid: ID!) {
121 repository(rid: $rid) {
122 accessControlList {
123 results {
124 id
125 mode
126 entity { canonicalName }
127 }
128 cursor
129 }
130 }
131 }
132 """
133
134 private static let updateACLMutation = """
135 mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
136 updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
137 id
138 mode
139 entity { canonicalName }
140 }
141 }
142 """
143
144 private static let deleteACLMutation = """
145 mutation deleteACL($id: Int!) {
146 deleteACL(id: $id) { id }
147 }
148 """
149
150 private static let deleteRepositoryMutation = """
151 mutation deleteRepository($id: Int!) {
152 deleteRepository(id: $id) { id }
153 }
154 """
155
156 private static let repositoryInfoQuery = """
157 query hgRepositoryInfo($rid: ID!) {
158 repository(rid: $rid) {
159 description
160 visibility
161 nonPublishing
162 }
163 }
164 """
165
166 func loadRepositoryInfo() async {
167 error = nil
168
169 do {
170 let result = try await client.execute(
171 service: .hg,
172 query: Self.repositoryInfoQuery,
173 variables: ["rid": repositoryRid],
174 responseType: HgRepositoryInfoResponse.self
175 )
176
177 if let repository = result.repository {
178 let description = repository.description ?? ""
179 initialDescription = description
180 initialVisibility = repository.visibility
181 editedDescription = description
182 editedVisibility = repository.visibility
183 let nonPublishing = repository.nonPublishing ?? false
184 editedNonPublishing = nonPublishing
185 loadedNonPublishing = nonPublishing
186 }
187 } catch {
188 self.error = error.userFacingMessage
189 }
190 }
191
192 func saveInfo() async -> Bool {
193 isSavingInfo = true
194 defer { isSavingInfo = false }
195 error = nil
196
197 do {
198 let input: [String: any Sendable] = [
199 "description": normalizedEditedDescription,
200 "visibility": editedVisibility.rawValue,
201 "nonPublishing": editedNonPublishing
202 ]
203 _ = try await client.execute(
204 service: .hg,
205 query: Self.updateRepositoryMutation,
206 variables: ["id": repositoryId, "input": input],
207 responseType: HgUpdateRepositoryResponse.self
208 )
209 initialDescription = normalizedEditedDescription
210 initialVisibility = editedVisibility
211 loadedNonPublishing = editedNonPublishing
212 return true
213 } catch {
214 self.error = error.userFacingMessage
215 return false
216 }
217 }
218
219 func loadACLs() async {
220 guard !isLoadingACLs else { return }
221 isLoadingACLs = true
222 defer { isLoadingACLs = false }
223 error = nil
224
225 do {
226 let result = try await client.execute(
227 service: .hg,
228 query: Self.accessControlListQuery,
229 variables: ["rid": repositoryRid],
230 responseType: HgACLResponse.self
231 )
232 acls = result.repository?.accessControlList.results ?? []
233 } catch {
234 self.error = error.userFacingMessage
235 }
236 }
237
238 func addACL() async {
239 let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
240 guard !rawEntity.isEmpty else { return }
241 let entity = hgCanonicalEntity(from: rawEntity)
242 isAddingACL = true
243 defer { isAddingACL = false }
244 error = nil
245
246 do {
247 let result = try await client.execute(
248 service: .hg,
249 query: Self.updateACLMutation,
250 variables: [
251 "repoId": repositoryId,
252 "mode": newACLMode,
253 "entity": entity
254 ],
255 responseType: HgUpdateACLResponse.self
256 )
257 if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
258 acls[index] = result.updateACL
259 } else {
260 acls.append(result.updateACL)
261 }
262 newACLEntity = ""
263 } catch {
264 self.error = error.userFacingMessage
265 }
266 }
267
268 private func hgCanonicalEntity(from input: String) -> String {
269 let username = input.hasPrefix("~") ? String(input.dropFirst()) : input
270 return "~\(username)"
271 }
272
273 func deleteACL(_ entry: HgACLEntry) async {
274 isDeletingACL = true
275 defer { isDeletingACL = false }
276 error = nil
277
278 do {
279 _ = try await client.execute(
280 service: .hg,
281 query: Self.deleteACLMutation,
282 variables: ["id": entry.id],
283 responseType: HgDeleteACLResponse.self
284 )
285 acls.removeAll { $0.id == entry.id }
286 } catch {
287 self.error = error.userFacingMessage
288 }
289 }
290
291 func deleteRepository() async {
292 isDeleting = true
293 defer { isDeleting = false }
294 error = nil
295
296 do {
297 _ = try await client.execute(
298 service: .hg,
299 query: Self.deleteRepositoryMutation,
300 variables: ["id": repositoryId],
301 responseType: HgDeleteRepositoryResponse.self
302 )
303 didDelete = true
304 } catch {
305 self.error = error.userFacingMessage
306 }
307 }
308}