krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: 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: [Reference]
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: [Reference],
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 {
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 } catch {
176 self.error = error.localizedDescription
177 }
178 }
179
180 // MARK: - Rename
181
182 func rename() async {
183 isRenaming = true
184 defer { isRenaming = false }
185 error = nil
186
187 do {
188 let input: [String: any Sendable] = [
189 "name": editedName
190 ]
191 let result = try await client.execute(
192 service: service,
193 query: Self.updateRepoMutation,
194 variables: ["id": repositoryId, "input": input],
195 responseType: UpdateRepoResponse.self
196 )
197 updatedName = result.updateRepository.name
198 } catch {
199 self.error = error.localizedDescription
200 }
201 }
202
203 // MARK: - ACLs
204
205 private static let aclsQuery = """
206 query acls($rid: ID!) {
207 repository(rid: $rid) {
208 acls {
209 results {
210 id
211 mode
212 entity { canonicalName }
213 }
214 cursor
215 }
216 }
217 }
218 """
219
220 private static let updateACLMutation = """
221 mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
222 updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
223 id mode entity { canonicalName }
224 }
225 }
226 """
227
228 private static let deleteACLMutation = """
229 mutation deleteACL($id: Int!) {
230 deleteACL(id: $id) { id }
231 }
232 """
233
234 private static let userLookupQuery = """
235 query userLookup($username: String!) {
236 user(username: $username) {
237 id
238 username
239 canonicalName
240 }
241 }
242 """
243
244 func loadACLs() async {
245 guard !isLoadingACLs else { return }
246 isLoadingACLs = true
247 defer { isLoadingACLs = false }
248
249 do {
250 let result = try await client.execute(
251 service: service,
252 query: Self.aclsQuery,
253 variables: ["rid": repositoryRid],
254 responseType: ACLResponse.self
255 )
256 acls = result.repository?.acls.results ?? []
257 } catch {
258 self.error = error.localizedDescription
259 }
260 }
261
262 func addACL() async {
263 let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
264 guard !rawEntity.isEmpty else { return }
265 let entity = Self.gitCanonicalEntity(from: rawEntity)
266 isAddingACL = true
267 defer { isAddingACL = false }
268 error = nil
269
270 do {
271 let result = try await client.execute(
272 service: service,
273 query: Self.updateACLMutation,
274 variables: [
275 "repoId": repositoryId,
276 "mode": newACLMode,
277 "entity": entity
278 ],
279 responseType: UpdateACLResponse.self
280 )
281 // Replace existing entry or append
282 if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
283 acls[index] = result.updateACL
284 } else {
285 acls.append(result.updateACL)
286 }
287 newACLEntity = ""
288 } catch {
289 self.error = error.localizedDescription
290 }
291 }
292
293 static func gitCanonicalEntity(from input: String) -> String {
294 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
295 guard !trimmed.isEmpty else { return trimmed }
296 let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
297 return "~\(username)"
298 }
299
300 static func gitHeadReference(from input: String) -> String {
301 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
302 guard !trimmed.isEmpty else { return trimmed }
303 if trimmed.hasPrefix("refs/") {
304 return trimmed
305 }
306 return "refs/heads/\(trimmed)"
307 }
308
309 func selectedHeadReferenceForSave() -> String? {
310 let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
311 guard normalizedEditedHead != originalEditedHead else {
312 return nil
313 }
314
315 return branches.first {
316 $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead
317 }?.name
318 }
319
320 func deleteACL(_ entry: ACLEntry) async {
321 isDeletingACL = true
322 defer { isDeletingACL = false }
323 error = nil
324
325 do {
326 _ = try await client.execute(
327 service: service,
328 query: Self.deleteACLMutation,
329 variables: ["id": entry.id],
330 responseType: DeleteACLResponse.self
331 )
332 acls.removeAll { $0.id == entry.id }
333 } catch {
334 self.error = error.localizedDescription
335 }
336 }
337
338 // MARK: - Delete Repository
339
340 private static let deleteRepoMutation = """
341 mutation deleteRepository($id: Int!) {
342 deleteRepository(id: $id) { id }
343 }
344 """
345
346 func deleteRepository() async {
347 isDeleting = true
348 defer { isDeleting = false }
349 error = nil
350
351 do {
352 _ = try await client.execute(
353 service: service,
354 query: Self.deleteRepoMutation,
355 variables: ["id": repositoryId],
356 responseType: DeleteRepoResponse.self
357 )
358 didDelete = true
359 } catch {
360 self.error = error.localizedDescription
361 }
362 }
363}