krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.11.0: Hutch/Views/Repositories/RepositorySettingsViewModel.swift · raw
1import Foundation
2
3// MARK: - Response types
4
5private struct UpdateRepoResponse: Decodable, Sendable {
6 let updateRepository: UpdatedRepo
7}
8
9private struct UpdateRepoInfoResponse: Decodable, Sendable {
10 let updateRepository: UpdatedRepoInfo
11}
12
13private struct UpdatedRepo: Decodable, Sendable {
14 let id: Int?
15 let rid: String?
16 let name: String
17 let description: String?
18 let visibility: Visibility?
19}
20
21private struct UpdatedRepoInfo: Decodable, Sendable {
22 let id: Int
23}
24
25private struct ACLResponse: Decodable, Sendable {
26 let repository: ACLRepository?
27}
28
29private struct ACLRepository: Decodable, Sendable {
30 let acls: ACLPage
31}
32
33private struct ACLPage: Decodable, Sendable {
34 let results: [ACLEntry]
35 let cursor: String?
36}
37
38private struct UpdateACLResponse: Decodable, Sendable {
39 let updateACL: ACLEntry
40}
41
42private struct DeleteACLResponse: Decodable, Sendable {
43 let deleteACL: DeletedACL
44}
45
46private struct DeletedACL: Decodable, Sendable {
47 let id: Int
48}
49
50private struct DeleteRepoResponse: Decodable, Sendable {
51 let deleteRepository: DeletedRepo
52}
53
54private struct DeletedRepo: Decodable, Sendable {
55 let id: Int
56}
57
58// MARK: - ACL Model
59
60struct ACLEntry: Decodable, Sendable, Identifiable {
61 let id: Int
62 let mode: String
63 let entity: Entity
64}
65
66// MARK: - View Model
67
68@Observable
69@MainActor
70final class RepositorySettingsViewModel {
71
72 let repositoryId: Int
73 let repositoryRid: String
74 let service: SRHTService
75 private let client: SRHTClient
76
77 // MARK: - Info fields
78
79 var editedDescription: String
80 var editedVisibility: Visibility
81 var editedHead: String
82 private let originalEditedHead: String
83 var isSavingInfo = false
84
85 // MARK: - Rename fields
86
87 var editedName: String
88 var isRenaming = false
89
90 // MARK: - ACL state
91
92 private(set) var acls: [ACLEntry] = []
93 private(set) var isLoadingACLs = false
94 var newACLEntity = ""
95 var newACLMode = "RO"
96 var isAddingACL = false
97 var isDeletingACL = false
98
99 // MARK: - Delete state
100
101 var isDeleting = false
102
103 // MARK: - Branches (for HEAD picker)
104
105 var branches: [ReferenceDetail]
106
107 // MARK: - Results
108
109 var error: String?
110 var updatedName: String?
111 var didDelete = false
112
113 init(
114 repository: RepositorySummary,
115 branches: [ReferenceDetail],
116 client: SRHTClient
117 ) {
118 self.repositoryId = repository.id
119 self.repositoryRid = repository.rid
120 self.service = repository.service
121 self.client = client
122 self.editedDescription = repository.description ?? ""
123 self.editedVisibility = repository.visibility
124 self.editedName = repository.name
125 self.branches = branches
126
127 // Extract branch name from HEAD reference
128 let initialEditedHead: String
129 if let head = repository.head?.name {
130 initialEditedHead = head.replacingOccurrences(of: "refs/heads/", with: "")
131 } else {
132 initialEditedHead = "main"
133 }
134 self.editedHead = initialEditedHead
135 self.originalEditedHead = initialEditedHead
136 }
137
138 // MARK: - Update Repository Info
139
140 private static let updateRepoMutation = """
141 mutation updateRepository($id: Int!, $input: RepoInput!) {
142 updateRepository(id: $id, input: $input) {
143 id rid name description visibility
144 }
145 }
146 """
147
148 private static let updateRepoInfoMutation = """
149 mutation updateRepository($id: Int!, $input: RepoInput!) {
150 updateRepository(id: $id, input: $input) {
151 id
152 }
153 }
154 """
155
156 func saveInfo() async -> Bool {
157 isSavingInfo = true
158 defer { isSavingInfo = false }
159 error = nil
160
161 do {
162 var input: [String: any Sendable] = [
163 "description": editedDescription,
164 "visibility": editedVisibility.rawValue
165 ]
166 if let headReference = selectedHeadReferenceForSave() {
167 input["HEAD"] = headReference
168 }
169 _ = try await client.execute(
170 service: service,
171 query: Self.updateRepoInfoMutation,
172 variables: ["id": repositoryId, "input": input],
173 responseType: UpdateRepoInfoResponse.self
174 )
175 return true
176 } catch {
177 self.error = error.userFacingMessage
178 return false
179 }
180 }
181
182 // MARK: - Rename
183
184 func rename() async {
185 isRenaming = true
186 defer { isRenaming = false }
187 error = nil
188
189 do {
190 let input: [String: any Sendable] = [
191 "name": editedName
192 ]
193 let result = try await client.execute(
194 service: service,
195 query: Self.updateRepoMutation,
196 variables: ["id": repositoryId, "input": input],
197 responseType: UpdateRepoResponse.self
198 )
199 updatedName = result.updateRepository.name
200 } catch {
201 self.error = error.userFacingMessage
202 }
203 }
204
205 // MARK: - ACLs
206
207 private static let aclsQuery = """
208 query acls($rid: ID!) {
209 repository(rid: $rid) {
210 acls {
211 results {
212 id
213 mode
214 entity { canonicalName }
215 }
216 cursor
217 }
218 }
219 }
220 """
221
222 private static let updateACLMutation = """
223 mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
224 updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
225 id mode entity { canonicalName }
226 }
227 }
228 """
229
230 private static let deleteACLMutation = """
231 mutation deleteACL($id: Int!) {
232 deleteACL(id: $id) { id }
233 }
234 """
235
236 private static let userLookupQuery = """
237 query userLookup($username: String!) {
238 user(username: $username) {
239 id
240 username
241 canonicalName
242 }
243 }
244 """
245
246 func loadACLs() async {
247 guard !isLoadingACLs else { return }
248 isLoadingACLs = true
249 defer { isLoadingACLs = false }
250
251 do {
252 let result = try await client.execute(
253 service: service,
254 query: Self.aclsQuery,
255 variables: ["rid": repositoryRid],
256 responseType: ACLResponse.self
257 )
258 acls = result.repository?.acls.results ?? []
259 } catch {
260 self.error = error.userFacingMessage
261 }
262 }
263
264 func addACL() async {
265 let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
266 guard !rawEntity.isEmpty else { return }
267 let entity = Self.gitCanonicalEntity(from: rawEntity)
268 isAddingACL = true
269 defer { isAddingACL = false }
270 error = nil
271
272 do {
273 let result = try await client.execute(
274 service: service,
275 query: Self.updateACLMutation,
276 variables: [
277 "repoId": repositoryId,
278 "mode": newACLMode,
279 "entity": entity
280 ],
281 responseType: UpdateACLResponse.self
282 )
283 // Replace existing entry or append
284 if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
285 acls[index] = result.updateACL
286 } else {
287 acls.append(result.updateACL)
288 }
289 newACLEntity = ""
290 } catch {
291 self.error = error.userFacingMessage
292 }
293 }
294
295 static func gitCanonicalEntity(from input: String) -> String {
296 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
297 guard !trimmed.isEmpty else { return trimmed }
298 let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
299 return "~\(username)"
300 }
301
302 static func gitHeadReference(from input: String) -> String {
303 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
304 guard !trimmed.isEmpty else { return trimmed }
305 if trimmed.hasPrefix("refs/") {
306 return trimmed
307 }
308 return "refs/heads/\(trimmed)"
309 }
310
311 func selectedHeadReferenceForSave() -> String? {
312 let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
313 guard normalizedEditedHead != originalEditedHead else {
314 return nil
315 }
316
317 return branches.first {
318 $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead
319 }?.name
320 }
321
322 func deleteACL(_ entry: ACLEntry) async {
323 isDeletingACL = true
324 defer { isDeletingACL = false }
325 error = nil
326
327 do {
328 _ = try await client.execute(
329 service: service,
330 query: Self.deleteACLMutation,
331 variables: ["id": entry.id],
332 responseType: DeleteACLResponse.self
333 )
334 acls.removeAll { $0.id == entry.id }
335 } catch {
336 self.error = error.userFacingMessage
337 }
338 }
339
340 // MARK: - Delete Repository
341
342 private static let deleteRepoMutation = """
343 mutation deleteRepository($id: Int!) {
344 deleteRepository(id: $id) { id }
345 }
346 """
347
348 func deleteRepository() async {
349 isDeleting = true
350 defer { isDeleting = false }
351 error = nil
352
353 do {
354 _ = try await client.execute(
355 service: service,
356 query: Self.deleteRepoMutation,
357 variables: ["id": repositoryId],
358 responseType: DeleteRepoResponse.self
359 )
360 didDelete = true
361 } catch {
362 self.error = error.userFacingMessage
363 }
364 }
365}