krz/hutch

an ios client for sourcehut

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

v2: Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift · raw

  1import Foundation
  2
  3private struct HgUpdateRepositoryResponse: Decodable, Sendable {
  4    let updateRepository: HgUpdatedRepository
  5}
  6
  7private struct HgUpdatedRepository: Decodable, Sendable {
  8    let id: Int
  9}
 10
 11private struct HgRepositoryInfoResponse: Decodable, Sendable {
 12    let repository: HgRepositoryInfo?
 13}
 14
 15private struct HgRepositoryInfo: Decodable, Sendable {
 16    let description: String?
 17    let visibility: Visibility
 18    let nonPublishing: Bool?
 19}
 20
 21private struct HgACLResponse: Decodable, Sendable {
 22    let repository: HgACLRepository?
 23}
 24
 25private struct HgACLRepository: Decodable, Sendable {
 26    let accessControlList: HgACLPage
 27}
 28
 29private struct HgACLPage: Decodable, Sendable {
 30    let results: [HgACLEntry]
 31    let cursor: String?
 32}
 33
 34private struct HgUpdateACLResponse: Decodable, Sendable {
 35    let updateACL: HgACLEntry
 36}
 37
 38private struct HgDeleteACLResponse: Decodable, Sendable {
 39    let deleteACL: HgDeletedACL
 40}
 41
 42private struct HgDeletedACL: Decodable, Sendable {
 43    let id: Int
 44}
 45
 46private struct HgDeleteRepositoryResponse: Decodable, Sendable {
 47    let deleteRepository: HgDeletedRepository
 48}
 49
 50private struct HgDeletedRepository: Decodable, Sendable {
 51    let id: Int
 52}
 53
 54struct HgACLEntry: Decodable, Sendable, Identifiable {
 55    let id: Int
 56    let mode: String
 57    let entity: Entity
 58}
 59
 60@Observable
 61@MainActor
 62final class HgRepositorySettingsViewModel {
 63    let repositoryId: Int
 64    let repositoryRid: String
 65    let repositoryName: String
 66    private let client: SRHTClient
 67
 68    var editedDescription: String
 69    var editedVisibility: Visibility
 70    var editedNonPublishing: Bool
 71    var isSavingInfo = false
 72
 73    private(set) var acls: [HgACLEntry] = []
 74    private(set) var isLoadingACLs = false
 75    var newACLEntity = ""
 76    var newACLMode = "RO"
 77    var isAddingACL = false
 78    var isDeletingACL = false
 79
 80    var histeditRevision = ""
 81    var isDeleting = false
 82    var didDelete = false
 83    var error: String?
 84
 85    init(repository: RepositorySummary, client: SRHTClient) {
 86        self.repositoryId = repository.id
 87        self.repositoryRid = repository.rid
 88        self.repositoryName = repository.name
 89        self.client = client
 90        self.editedDescription = repository.description ?? ""
 91        self.editedVisibility = repository.visibility
 92        self.editedNonPublishing = false
 93    }
 94
 95    private static let updateRepositoryMutation = """
 96    mutation updateRepository($id: Int!, $input: RepoInput!) {
 97        updateRepository(id: $id, input: $input) {
 98            id
 99        }
100    }
101    """
102
103    private static let accessControlListQuery = """
104    query hgAccessControlList($rid: ID!) {
105        repository(rid: $rid) {
106            accessControlList {
107                results {
108                    id
109                    mode
110                    entity { canonicalName }
111                }
112                cursor
113            }
114        }
115    }
116    """
117
118    private static let updateACLMutation = """
119    mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
120        updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
121            id
122            mode
123            entity { canonicalName }
124        }
125    }
126    """
127
128    private static let deleteACLMutation = """
129    mutation deleteACL($id: Int!) {
130        deleteACL(id: $id) { id }
131    }
132    """
133
134    private static let deleteRepositoryMutation = """
135    mutation deleteRepository($id: Int!) {
136        deleteRepository(id: $id) { id }
137    }
138    """
139
140    private static let repositoryInfoQuery = """
141    query hgRepositoryInfo($rid: ID!) {
142        repository(rid: $rid) {
143            description
144            visibility
145            nonPublishing
146        }
147    }
148    """
149
150    func loadRepositoryInfo() async {
151        error = nil
152
153        do {
154            let result = try await client.execute(
155                service: .hg,
156                query: Self.repositoryInfoQuery,
157                variables: ["rid": repositoryRid],
158                responseType: HgRepositoryInfoResponse.self
159            )
160
161            if let repository = result.repository {
162                editedDescription = repository.description ?? ""
163                editedVisibility = repository.visibility
164                editedNonPublishing = repository.nonPublishing ?? false
165            }
166        } catch {
167            self.error = error.localizedDescription
168        }
169    }
170
171    func saveInfo() async {
172        isSavingInfo = true
173        defer { isSavingInfo = false }
174        error = nil
175
176        do {
177            let input: [String: any Sendable] = [
178                "description": editedDescription,
179                "visibility": editedVisibility.rawValue,
180                "nonPublishing": editedNonPublishing
181            ]
182            _ = try await client.execute(
183                service: .hg,
184                query: Self.updateRepositoryMutation,
185                variables: ["id": repositoryId, "input": input],
186                responseType: HgUpdateRepositoryResponse.self
187            )
188        } catch {
189            self.error = error.localizedDescription
190        }
191    }
192
193    func loadACLs() async {
194        guard !isLoadingACLs else { return }
195        isLoadingACLs = true
196        defer { isLoadingACLs = false }
197        error = nil
198
199        do {
200            let result = try await client.execute(
201                service: .hg,
202                query: Self.accessControlListQuery,
203                variables: ["rid": repositoryRid],
204                responseType: HgACLResponse.self
205            )
206            acls = result.repository?.accessControlList.results ?? []
207        } catch {
208            self.error = error.localizedDescription
209        }
210    }
211
212    func addACL() async {
213        let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
214        guard !rawEntity.isEmpty else { return }
215        let entity = hgCanonicalEntity(from: rawEntity)
216        isAddingACL = true
217        defer { isAddingACL = false }
218        error = nil
219
220        do {
221            let result = try await client.execute(
222                service: .hg,
223                query: Self.updateACLMutation,
224                variables: [
225                    "repoId": repositoryId,
226                    "mode": newACLMode,
227                    "entity": entity
228                ],
229                responseType: HgUpdateACLResponse.self
230            )
231            if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
232                acls[index] = result.updateACL
233            } else {
234                acls.append(result.updateACL)
235            }
236            newACLEntity = ""
237        } catch {
238            let message = error.localizedDescription
239            if message.localizedCaseInsensitiveContains("No such repository or user found") {
240                self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first."
241            } else {
242                self.error = message
243            }
244        }
245    }
246
247    private func hgCanonicalEntity(from input: String) -> String {
248        let username = input.hasPrefix("~") ? String(input.dropFirst()) : input
249        return "~\(username)"
250    }
251
252    func deleteACL(_ entry: HgACLEntry) async {
253        isDeletingACL = true
254        defer { isDeletingACL = false }
255        error = nil
256
257        do {
258            _ = try await client.execute(
259                service: .hg,
260                query: Self.deleteACLMutation,
261                variables: ["id": entry.id],
262                responseType: HgDeleteACLResponse.self
263            )
264            acls.removeAll { $0.id == entry.id }
265        } catch {
266            self.error = error.localizedDescription
267        }
268    }
269
270    func deleteRepository() async {
271        isDeleting = true
272        defer { isDeleting = false }
273        error = nil
274
275        do {
276            _ = try await client.execute(
277                service: .hg,
278                query: Self.deleteRepositoryMutation,
279                variables: ["id": repositoryId],
280                responseType: HgDeleteRepositoryResponse.self
281            )
282            didDelete = true
283        } catch {
284            self.error = error.localizedDescription
285        }
286    }
287}