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}