krz/hutch

an ios client for sourcehut

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

v3.0.0: Hutch/Views/Repositories/RepositorySettingsViewModel.swift · raw

  1import Foundation
  2
  3private struct UpdateRepositoryResponse: Decodable, Sendable {
  4    let updateRepository: UpdatedRepositoryPayload
  5}
  6
  7private struct UpdatedRepositoryPayload: Decodable, Sendable {
  8    let id: Int
  9    let rid: String
 10    let name: String
 11    let description: String?
 12    let visibility: Visibility
 13    let updated: Date
 14    let head: Reference?
 15
 16    enum CodingKeys: String, CodingKey {
 17        case id, rid, name, description, visibility, updated
 18        case head = "HEAD"
 19    }
 20
 21    func repositorySummary(using owner: Entity, service: SRHTService) -> RepositorySummary {
 22        RepositorySummary(
 23            id: id,
 24            rid: rid,
 25            service: service,
 26            name: name,
 27            description: description,
 28            visibility: visibility,
 29            updated: updated,
 30            owner: owner,
 31            head: head
 32        )
 33    }
 34}
 35
 36private struct DeleteRepositoryResponse: Decodable, Sendable {
 37    let deleteRepository: DeletedRepositoryPayload
 38}
 39
 40private struct DeletedRepositoryPayload: Decodable, Sendable {
 41    let id: Int
 42}
 43
 44@Observable
 45@MainActor
 46final class RepositorySettingsViewModel {
 47    let repositoryId: Int
 48    let repositoryRid: String
 49    let service: SRHTService
 50
 51    private let client: SRHTClient
 52    private(set) var repository: RepositorySummary
 53
 54    var editedName: String
 55    var editedDescription: String
 56    var editedVisibility: Visibility
 57    var editedHead: String
 58
 59    private(set) var branches: [ReferenceDetail]
 60    var isSavingMetadata = false
 61    var isSavingDefaultBranch = false
 62    var isUpdatingVisibility = false
 63    var isDeleting = false
 64    var error: String?
 65    var didDelete = false
 66
 67    var isMutating: Bool {
 68        isSavingMetadata || isSavingDefaultBranch || isUpdatingVisibility || isDeleting
 69    }
 70
 71    var currentDefaultBranchName: String {
 72        repository.defaultBranchName ?? "Not set"
 73    }
 74
 75    var availableBranchNames: [String] {
 76        branches.map { RepositorySummary.displayBranchName(for: $0.name) }
 77    }
 78
 79    var normalizedEditedName: String {
 80        editedName.trimmingCharacters(in: .whitespacesAndNewlines)
 81    }
 82
 83    var normalizedEditedDescription: String {
 84        editedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
 85    }
 86
 87    var metadataValidationMessage: String? {
 88        Self.metadataValidationMessage(for: normalizedEditedName)
 89    }
 90
 91    var defaultBranchValidationMessage: String? {
 92        guard !branches.isEmpty else {
 93            return "This repository doesn't have any branches yet."
 94        }
 95        let normalizedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
 96        guard !normalizedHead.isEmpty else {
 97            return "Select a default branch."
 98        }
 99        guard availableBranchNames.contains(normalizedHead) else {
100            return "Select one of the available branches."
101        }
102        return nil
103    }
104
105    var isMetadataDirty: Bool {
106        normalizedEditedName != repository.name ||
107        normalizedEditedDescription != (repository.description ?? "")
108    }
109
110    var isDefaultBranchDirty: Bool {
111        editedHead.trimmingCharacters(in: .whitespacesAndNewlines) != (repository.defaultBranchName ?? "")
112    }
113
114    var isVisibilityDirty: Bool {
115        editedVisibility != repository.visibility
116    }
117
118    init(
119        repository: RepositorySummary,
120        branches: [ReferenceDetail],
121        client: SRHTClient
122    ) {
123        self.repositoryId = repository.id
124        self.repositoryRid = repository.rid
125        self.service = repository.service
126        self.client = client
127        self.repository = repository
128        self.branches = branches
129        self.editedName = repository.name
130        self.editedDescription = repository.description ?? ""
131        self.editedVisibility = repository.visibility
132        self.editedHead = repository.defaultBranchName ?? ""
133    }
134
135    private static let updateRepositoryMutation = """
136    mutation updateRepository($id: Int!, $input: RepoInput!) {
137        updateRepository(id: $id, input: $input) {
138            id
139            rid
140            name
141            description
142            visibility
143            updated
144            HEAD { name target }
145        }
146    }
147    """
148
149    private static let deleteRepositoryMutation = """
150    mutation deleteRepository($id: Int!) {
151        deleteRepository(id: $id) { id }
152    }
153    """
154
155    func saveMetadata() async -> RepositorySummary? {
156        guard !isMutating else { return nil }
157        if let metadataValidationMessage {
158            error = metadataValidationMessage
159            return nil
160        }
161        guard isMetadataDirty else { return repository }
162
163        isSavingMetadata = true
164        defer { isSavingMetadata = false }
165        error = nil
166
167        let input: [String: any Sendable] = [
168            "name": normalizedEditedName,
169            "description": normalizedEditedDescription
170        ]
171
172        do {
173            return try await updateRepository(with: input)
174        } catch {
175            self.error = "Couldn't update repository details. \(error.userFacingMessage)"
176            return nil
177        }
178    }
179
180    func saveDefaultBranch() async -> RepositorySummary? {
181        guard !isMutating else { return nil }
182        if let defaultBranchValidationMessage {
183            error = defaultBranchValidationMessage
184            return nil
185        }
186        guard isDefaultBranchDirty else { return repository }
187        guard let headReference = selectedHeadReferenceForSave() else {
188            error = "Select one of the available branches."
189            return nil
190        }
191
192        isSavingDefaultBranch = true
193        defer { isSavingDefaultBranch = false }
194        error = nil
195
196        do {
197            return try await updateRepository(with: ["HEAD": headReference])
198        } catch {
199            self.error = "Couldn't update the default branch. \(error.userFacingMessage)"
200            return nil
201        }
202    }
203
204    func updateVisibility() async -> RepositorySummary? {
205        guard !isMutating else { return nil }
206        guard isVisibilityDirty else { return repository }
207
208        isUpdatingVisibility = true
209        defer { isUpdatingVisibility = false }
210        error = nil
211
212        do {
213            return try await updateRepository(with: ["visibility": editedVisibility.rawValue])
214        } catch {
215            self.error = "Couldn't update visibility. \(error.userFacingMessage)"
216            editedVisibility = repository.visibility
217            return nil
218        }
219    }
220
221    func deleteRepository() async {
222        guard !isMutating else { return }
223
224        isDeleting = true
225        defer { isDeleting = false }
226        error = nil
227
228        do {
229            _ = try await client.execute(
230                service: service,
231                query: Self.deleteRepositoryMutation,
232                variables: ["id": repositoryId],
233                responseType: DeleteRepositoryResponse.self
234            )
235            didDelete = true
236        } catch {
237            self.error = error.userFacingMessage
238        }
239    }
240
241    private func updateRepository(with input: [String: any Sendable]) async throws -> RepositorySummary {
242        let result = try await client.execute(
243            service: service,
244            query: Self.updateRepositoryMutation,
245            variables: ["id": repositoryId, "input": input],
246            responseType: UpdateRepositoryResponse.self
247        )
248        let updatedRepository = result.updateRepository.repositorySummary(
249            using: repository.owner,
250            service: service
251        )
252        apply(updatedRepository)
253        return updatedRepository
254    }
255
256    private func apply(_ updatedRepository: RepositorySummary) {
257        repository = updatedRepository
258        editedName = updatedRepository.name
259        editedDescription = updatedRepository.description ?? ""
260        editedVisibility = updatedRepository.visibility
261        editedHead = updatedRepository.defaultBranchName ?? ""
262    }
263
264    static func metadataValidationMessage(for name: String) -> String? {
265        guard !name.isEmpty else {
266            return "Enter a repository name."
267        }
268        guard !name.contains("/") else {
269            return "Repository names can't contain '/'."
270        }
271        guard name.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else {
272            return "Repository names can't contain spaces."
273        }
274        return nil
275    }
276
277    static func gitHeadReference(from input: String) -> String {
278        let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
279        guard !trimmed.isEmpty else { return trimmed }
280        if trimmed.hasPrefix("refs/") {
281            return trimmed
282        }
283        return "refs/heads/\(trimmed)"
284    }
285
286    func selectedHeadReferenceForSave() -> String? {
287        let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
288        guard normalizedEditedHead != (repository.defaultBranchName ?? "") else {
289            return nil
290        }
291
292        return branches.first {
293            RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead
294        }?.name
295    }
296}