krz/hutch

an ios client for sourcehut

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

v3.7.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            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 = metadataInputForSave()
170
171        do {
172            return try await updateRepository(with: input)
173        } catch {
174            self.error = "Couldn't update repository details. \(error.userFacingMessage)"
175            return nil
176        }
177    }
178
179    func saveDefaultBranch() async -> RepositorySummary? {
180        guard !isMutating else { return nil }
181        if let defaultBranchValidationMessage {
182            error = defaultBranchValidationMessage
183            return nil
184        }
185        guard isDefaultBranchDirty else { return repository }
186        guard let headReference = selectedHeadReferenceForSave() else {
187            error = "Select one of the available branches."
188            return nil
189        }
190
191        isSavingDefaultBranch = true
192        defer { isSavingDefaultBranch = false }
193        error = nil
194
195        do {
196            return try await updateRepository(with: ["HEAD": headReference])
197        } catch {
198            self.error = "Couldn't update the default branch. \(error.userFacingMessage)"
199            return nil
200        }
201    }
202
203    func updateVisibility() async -> RepositorySummary? {
204        guard !isMutating else { return nil }
205        guard isVisibilityDirty else { return repository }
206
207        isUpdatingVisibility = true
208        defer { isUpdatingVisibility = false }
209        error = nil
210
211        do {
212            return try await updateRepository(with: ["visibility": editedVisibility.rawValue])
213        } catch {
214            self.error = "Couldn't update visibility. \(error.userFacingMessage)"
215            editedVisibility = repository.visibility
216            return nil
217        }
218    }
219
220    func deleteRepository() async {
221        guard !isMutating else { return }
222
223        isDeleting = true
224        defer { isDeleting = false }
225        error = nil
226
227        do {
228            _ = try await client.execute(
229                service: service,
230                query: Self.deleteRepositoryMutation,
231                variables: ["id": repositoryId],
232                responseType: DeleteRepositoryResponse.self
233            )
234            didDelete = true
235        } catch {
236            self.error = error.userFacingMessage
237        }
238    }
239
240    private func updateRepository(with input: [String: any Sendable]) async throws -> RepositorySummary {
241        let result = try await client.execute(
242            service: service,
243            query: Self.updateRepositoryMutation,
244            variables: ["id": repositoryId, "input": input],
245            responseType: UpdateRepositoryResponse.self
246        )
247        let updatedRepository = result.updateRepository.repositorySummary(
248            using: repository.owner,
249            service: service
250        )
251        apply(updatedRepository)
252        return updatedRepository
253    }
254
255    private func apply(_ updatedRepository: RepositorySummary) {
256        repository = updatedRepository
257        editedName = updatedRepository.name
258        editedDescription = updatedRepository.description ?? ""
259        editedVisibility = updatedRepository.visibility
260        editedHead = updatedRepository.defaultBranchName ?? ""
261    }
262
263    static func metadataValidationMessage(for name: String) -> String? {
264        guard !name.isEmpty else {
265            return "Enter a repository name."
266        }
267        guard !name.contains("/") else {
268            return "Repository names can't contain '/'."
269        }
270        guard name.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else {
271            return "Repository names can't contain spaces."
272        }
273        return nil
274    }
275
276    static func gitHeadReference(from input: String) -> String {
277        let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
278        guard !trimmed.isEmpty else { return trimmed }
279        if trimmed.hasPrefix("refs/") {
280            return trimmed
281        }
282        return "refs/heads/\(trimmed)"
283    }
284
285    func selectedHeadReferenceForSave() -> String? {
286        let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
287        guard normalizedEditedHead != (repository.defaultBranchName ?? "") else {
288            return nil
289        }
290
291        return branches.first {
292            RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead
293        }?.name
294    }
295
296    func metadataInputForSave() -> [String: any Sendable] {
297        var input: [String: any Sendable] = [:]
298
299        if normalizedEditedName != repository.name {
300            input["name"] = normalizedEditedName
301        }
302
303        if normalizedEditedDescription != (repository.description ?? "") {
304            if normalizedEditedDescription.isEmpty {
305                // Assigning nil through the subscript would remove the key and omit
306                // `description` from the mutation, leaving the old value in place.
307                // updateValue stores an explicit nil, which AnyCodable encodes as null.
308                input.updateValue(Optional<String>.none as any Sendable, forKey: "description")
309            } else {
310                input["description"] = normalizedEditedDescription
311            }
312        }
313
314        return input
315    }
316}