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