krz/hutch

an ios client for sourcehut

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

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