Hutch/Views/Repositories/RepositorySettingsViewModel.swift

b5af561d68e7535726b79d4ceec0d0a45abf0042
hutch/Hutch/Views/Repositories/RepositorySettingsViewModel.swift history · blame · raw

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}