krz/hutch

an ios client for sourcehut

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

v3.8.0: 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    private var initialDescription: String
 68    private var initialVisibility: Visibility
 69
 70    var editedDescription: String
 71    var editedVisibility: Visibility
 72    var editedNonPublishing: Bool
 73    var isSavingInfo = false
 74    private(set) var loadedNonPublishing = false
 75
 76    private(set) var acls: [HgACLEntry] = []
 77    private(set) var isLoadingACLs = false
 78    var newACLEntity = ""
 79    var newACLMode = "RO"
 80    var isAddingACL = false
 81    var isDeletingACL = false
 82
 83    var histeditRevision = ""
 84    var isDeleting = false
 85    var didDelete = false
 86    var error: String?
 87
 88    var normalizedEditedDescription: String {
 89        editedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
 90    }
 91
 92    var isInfoDirty: Bool {
 93        normalizedEditedDescription != initialDescription ||
 94        editedVisibility != initialVisibility ||
 95        editedNonPublishing != loadedNonPublishing
 96    }
 97
 98    init(repository: RepositorySummary, client: SRHTClient) {
 99        self.repositoryId = repository.id
100        self.repositoryRid = repository.rid
101        self.repositoryName = repository.name
102        self.client = client
103        let description = repository.description ?? ""
104        self.initialDescription = description
105        self.initialVisibility = repository.visibility
106        self.editedDescription = description
107        self.editedVisibility = repository.visibility
108        self.editedNonPublishing = false
109    }
110
111    private static let updateRepositoryMutation = """
112    mutation updateRepository($id: Int!, $input: RepoInput!) {
113        updateRepository(id: $id, input: $input) {
114            id
115        }
116    }
117    """
118
119    private static let accessControlListQuery = """
120    query hgAccessControlList($rid: ID!) {
121        repository(rid: $rid) {
122            accessControlList {
123                results {
124                    id
125                    mode
126                    entity { canonicalName }
127                }
128                cursor
129            }
130        }
131    }
132    """
133
134    private static let updateACLMutation = """
135    mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
136        updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
137            id
138            mode
139            entity { canonicalName }
140        }
141    }
142    """
143
144    private static let deleteACLMutation = """
145    mutation deleteACL($id: Int!) {
146        deleteACL(id: $id) { id }
147    }
148    """
149
150    private static let deleteRepositoryMutation = """
151    mutation deleteRepository($id: Int!) {
152        deleteRepository(id: $id) { id }
153    }
154    """
155
156    private static let repositoryInfoQuery = """
157    query hgRepositoryInfo($rid: ID!) {
158        repository(rid: $rid) {
159            description
160            visibility
161            nonPublishing
162        }
163    }
164    """
165
166    func loadRepositoryInfo() async {
167        error = nil
168
169        do {
170            let result = try await client.execute(
171                service: .hg,
172                query: Self.repositoryInfoQuery,
173                variables: ["rid": repositoryRid],
174                responseType: HgRepositoryInfoResponse.self
175            )
176
177            if let repository = result.repository {
178                let description = repository.description ?? ""
179                initialDescription = description
180                initialVisibility = repository.visibility
181                editedDescription = description
182                editedVisibility = repository.visibility
183                let nonPublishing = repository.nonPublishing ?? false
184                editedNonPublishing = nonPublishing
185                loadedNonPublishing = nonPublishing
186            }
187        } catch {
188            self.error = error.userFacingMessage
189        }
190    }
191
192    func saveInfo() async -> Bool {
193        isSavingInfo = true
194        defer { isSavingInfo = false }
195        error = nil
196
197        do {
198            let input: [String: any Sendable] = [
199                "description": normalizedEditedDescription,
200                "visibility": editedVisibility.rawValue,
201                "nonPublishing": editedNonPublishing
202            ]
203            _ = try await client.execute(
204                service: .hg,
205                query: Self.updateRepositoryMutation,
206                variables: ["id": repositoryId, "input": input],
207                responseType: HgUpdateRepositoryResponse.self
208            )
209            initialDescription = normalizedEditedDescription
210            initialVisibility = editedVisibility
211            loadedNonPublishing = editedNonPublishing
212            return true
213        } catch {
214            self.error = error.userFacingMessage
215            return false
216        }
217    }
218
219    func loadACLs() async {
220        guard !isLoadingACLs else { return }
221        isLoadingACLs = true
222        defer { isLoadingACLs = false }
223        error = nil
224
225        do {
226            let result = try await client.execute(
227                service: .hg,
228                query: Self.accessControlListQuery,
229                variables: ["rid": repositoryRid],
230                responseType: HgACLResponse.self
231            )
232            acls = result.repository?.accessControlList.results ?? []
233        } catch {
234            self.error = error.userFacingMessage
235        }
236    }
237
238    func addACL() async {
239        let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
240        guard !rawEntity.isEmpty else { return }
241        let entity = hgCanonicalEntity(from: rawEntity)
242        isAddingACL = true
243        defer { isAddingACL = false }
244        error = nil
245
246        do {
247            let result = try await client.execute(
248                service: .hg,
249                query: Self.updateACLMutation,
250                variables: [
251                    "repoId": repositoryId,
252                    "mode": newACLMode,
253                    "entity": entity
254                ],
255                responseType: HgUpdateACLResponse.self
256            )
257            if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
258                acls[index] = result.updateACL
259            } else {
260                acls.append(result.updateACL)
261            }
262            newACLEntity = ""
263        } catch {
264            self.error = error.userFacingMessage
265        }
266    }
267
268    private func hgCanonicalEntity(from input: String) -> String {
269        let username = input.hasPrefix("~") ? String(input.dropFirst()) : input
270        return "~\(username)"
271    }
272
273    func deleteACL(_ entry: HgACLEntry) async {
274        isDeletingACL = true
275        defer { isDeletingACL = false }
276        error = nil
277
278        do {
279            _ = try await client.execute(
280                service: .hg,
281                query: Self.deleteACLMutation,
282                variables: ["id": entry.id],
283                responseType: HgDeleteACLResponse.self
284            )
285            acls.removeAll { $0.id == entry.id }
286        } catch {
287            self.error = error.userFacingMessage
288        }
289    }
290
291    func deleteRepository() async {
292        isDeleting = true
293        defer { isDeleting = false }
294        error = nil
295
296        do {
297            _ = try await client.execute(
298                service: .hg,
299                query: Self.deleteRepositoryMutation,
300                variables: ["id": repositoryId],
301                responseType: HgDeleteRepositoryResponse.self
302            )
303            didDelete = true
304        } catch {
305            self.error = error.userFacingMessage
306        }
307    }
308}