krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.8.2: 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}