diff options
Diffstat (limited to 'Hutch/Views/Repositories/RepositorySettingsViewModel.swift')
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySettingsViewModel.swift | 320 |
1 files changed, 204 insertions, 116 deletions
diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift index 30fe040..8bd80a1 100644 --- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift +++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift @@ -1,72 +1,119 @@ import Foundation -// MARK: - Response types - -private struct UpdateRepoResponse: Decodable, Sendable { - let updateRepository: UpdatedRepo -} - -private struct UpdateRepoInfoResponse: Decodable, Sendable { - let updateRepository: UpdatedRepoInfo +private struct UpdateRepositoryResponse: Decodable, Sendable { + let updateRepository: UpdatedRepositoryPayload } -private struct UpdatedRepo: Decodable, Sendable { - let id: Int? - let rid: String? +private struct UpdatedRepositoryPayload: Decodable, Sendable { + let id: Int + let rid: String let name: String let description: String? - let visibility: Visibility? -} + let visibility: Visibility + let updated: Date + let head: Reference? -private struct UpdatedRepoInfo: Decodable, Sendable { - let id: Int + enum CodingKeys: String, CodingKey { + case id, rid, name, description, visibility, updated + case head = "HEAD" + } + + func repositorySummary(using owner: Entity, service: SRHTService) -> RepositorySummary { + RepositorySummary( + id: id, + rid: rid, + service: service, + name: name, + description: description, + visibility: visibility, + updated: updated, + owner: owner, + head: head + ) + } } -private struct DeleteRepoResponse: Decodable, Sendable { - let deleteRepository: DeletedRepo +private struct DeleteRepositoryResponse: Decodable, Sendable { + let deleteRepository: DeletedRepositoryPayload } -private struct DeletedRepo: Decodable, Sendable { +private struct DeletedRepositoryPayload: Decodable, Sendable { let id: Int } -// MARK: - View Model - @Observable @MainActor final class RepositorySettingsViewModel { - let repositoryId: Int let repositoryRid: String let service: SRHTService - private let client: SRHTClient - // MARK: - Info fields + private let client: SRHTClient + private(set) var repository: RepositorySummary + var editedName: String var editedDescription: String var editedVisibility: Visibility var editedHead: String - private let originalEditedHead: String - var isSavingInfo = false - // MARK: - Rename fields + private(set) var branches: [ReferenceDetail] + var isSavingMetadata = false + var isSavingDefaultBranch = false + var isUpdatingVisibility = false + var isDeleting = false + var error: String? + var didDelete = false - var editedName: String - var isRenaming = false + var isMutating: Bool { + isSavingMetadata || isSavingDefaultBranch || isUpdatingVisibility || isDeleting + } - // MARK: - Delete state + var currentDefaultBranchName: String { + repository.defaultBranchName ?? "Not set" + } - var isDeleting = false + var availableBranchNames: [String] { + branches.map { RepositorySummary.displayBranchName(for: $0.name) } + } - // MARK: - Branches (for HEAD picker) + var normalizedEditedName: String { + editedName.trimmingCharacters(in: .whitespacesAndNewlines) + } - var branches: [ReferenceDetail] + var normalizedEditedDescription: String { + editedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + } - // MARK: - Results + var metadataValidationMessage: String? { + Self.metadataValidationMessage(for: normalizedEditedName) + } - var error: String? - var updatedName: String? - var didDelete = false + var defaultBranchValidationMessage: String? { + guard !branches.isEmpty else { + return "This repository doesn't have any branches yet." + } + let normalizedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedHead.isEmpty else { + return "Select a default branch." + } + guard availableBranchNames.contains(normalizedHead) else { + return "Select one of the available branches." + } + return nil + } + + var isMetadataDirty: Bool { + normalizedEditedName != repository.name || + normalizedEditedDescription != (repository.description ?? "") + } + + var isDefaultBranchDirty: Bool { + editedHead.trimmingCharacters(in: .whitespacesAndNewlines) != (repository.defaultBranchName ?? "") + } + + var isVisibilityDirty: Bool { + editedVisibility != repository.visibility + } init( repository: RepositorySummary, @@ -77,118 +124,103 @@ final class RepositorySettingsViewModel { self.repositoryRid = repository.rid self.service = repository.service self.client = client + self.repository = repository + self.branches = branches + self.editedName = repository.name self.editedDescription = repository.description ?? "" self.editedVisibility = repository.visibility - self.editedName = repository.name - self.branches = branches - - // Extract branch name from HEAD reference - let initialEditedHead: String - if let head = repository.head?.name { - initialEditedHead = head.replacingOccurrences(of: "refs/heads/", with: "") - } else { - initialEditedHead = "main" - } - self.editedHead = initialEditedHead - self.originalEditedHead = initialEditedHead + self.editedHead = repository.defaultBranchName ?? "" } - // MARK: - Update Repository Info - - private static let updateRepoMutation = """ + private static let updateRepositoryMutation = """ mutation updateRepository($id: Int!, $input: RepoInput!) { updateRepository(id: $id, input: $input) { - id rid name description visibility + id + rid + name + description + visibility + updated + HEAD { name target } } } """ - private static let updateRepoInfoMutation = """ - mutation updateRepository($id: Int!, $input: RepoInput!) { - updateRepository(id: $id, input: $input) { - id - } + private static let deleteRepositoryMutation = """ + mutation deleteRepository($id: Int!) { + deleteRepository(id: $id) { id } } """ - func saveInfo() async -> Bool { - isSavingInfo = true - defer { isSavingInfo = false } + func saveMetadata() async -> RepositorySummary? { + guard !isMutating else { return nil } + if let metadataValidationMessage { + error = metadataValidationMessage + return nil + } + guard isMetadataDirty else { return repository } + + isSavingMetadata = true + defer { isSavingMetadata = false } error = nil + let input: [String: any Sendable] = [ + "name": normalizedEditedName, + "description": normalizedEditedDescription + ] + do { - var input: [String: any Sendable] = [ - "description": editedDescription, - "visibility": editedVisibility.rawValue - ] - if let headReference = selectedHeadReferenceForSave() { - input["HEAD"] = headReference - } - _ = try await client.execute( - service: service, - query: Self.updateRepoInfoMutation, - variables: ["id": repositoryId, "input": input], - responseType: UpdateRepoInfoResponse.self - ) - return true + return try await updateRepository(with: input) } catch { - self.error = error.userFacingMessage - return false + self.error = "Couldn't update repository details. \(error.userFacingMessage)" + return nil } } - // MARK: - Rename + func saveDefaultBranch() async -> RepositorySummary? { + guard !isMutating else { return nil } + if let defaultBranchValidationMessage { + error = defaultBranchValidationMessage + return nil + } + guard isDefaultBranchDirty else { return repository } + guard let headReference = selectedHeadReferenceForSave() else { + error = "Select one of the available branches." + return nil + } - func rename() async { - isRenaming = true - defer { isRenaming = false } + isSavingDefaultBranch = true + defer { isSavingDefaultBranch = false } error = nil do { - let input: [String: any Sendable] = [ - "name": editedName - ] - let result = try await client.execute( - service: service, - query: Self.updateRepoMutation, - variables: ["id": repositoryId, "input": input], - responseType: UpdateRepoResponse.self - ) - updatedName = result.updateRepository.name + return try await updateRepository(with: ["HEAD": headReference]) } catch { - self.error = error.userFacingMessage + self.error = "Couldn't update the default branch. \(error.userFacingMessage)" + return nil } } - static func gitHeadReference(from input: String) -> String { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return trimmed } - if trimmed.hasPrefix("refs/") { - return trimmed - } - return "refs/heads/\(trimmed)" - } + func updateVisibility() async -> RepositorySummary? { + guard !isMutating else { return nil } + guard isVisibilityDirty else { return repository } - func selectedHeadReferenceForSave() -> String? { - let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines) - guard normalizedEditedHead != originalEditedHead else { + isUpdatingVisibility = true + defer { isUpdatingVisibility = false } + error = nil + + do { + return try await updateRepository(with: ["visibility": editedVisibility.rawValue]) + } catch { + self.error = "Couldn't update visibility. \(error.userFacingMessage)" + editedVisibility = repository.visibility return nil } - - return branches.first { - $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead - }?.name } - // MARK: - Delete Repository - - private static let deleteRepoMutation = """ - mutation deleteRepository($id: Int!) { - deleteRepository(id: $id) { id } - } - """ - func deleteRepository() async { + guard !isMutating else { return } + isDeleting = true defer { isDeleting = false } error = nil @@ -196,13 +228,69 @@ final class RepositorySettingsViewModel { do { _ = try await client.execute( service: service, - query: Self.deleteRepoMutation, + query: Self.deleteRepositoryMutation, variables: ["id": repositoryId], - responseType: DeleteRepoResponse.self + responseType: DeleteRepositoryResponse.self ) didDelete = true } catch { self.error = error.userFacingMessage } } + + private func updateRepository(with input: [String: any Sendable]) async throws -> RepositorySummary { + let result = try await client.execute( + service: service, + query: Self.updateRepositoryMutation, + variables: ["id": repositoryId, "input": input], + responseType: UpdateRepositoryResponse.self + ) + let updatedRepository = result.updateRepository.repositorySummary( + using: repository.owner, + service: service + ) + apply(updatedRepository) + return updatedRepository + } + + private func apply(_ updatedRepository: RepositorySummary) { + repository = updatedRepository + editedName = updatedRepository.name + editedDescription = updatedRepository.description ?? "" + editedVisibility = updatedRepository.visibility + editedHead = updatedRepository.defaultBranchName ?? "" + } + + static func metadataValidationMessage(for name: String) -> String? { + guard !name.isEmpty else { + return "Enter a repository name." + } + guard !name.contains("/") else { + return "Repository names can't contain '/'." + } + guard name.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else { + return "Repository names can't contain spaces." + } + return nil + } + + static func gitHeadReference(from input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + if trimmed.hasPrefix("refs/") { + return trimmed + } + return "refs/heads/\(trimmed)" + } + + func selectedHeadReferenceForSave() -> String? { + let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedEditedHead != (repository.defaultBranchName ?? "") else { + return nil + } + + return branches.first { + RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead + }?.name + } } |
