summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 22:30:22 -0500
committerChristian Cleberg <[email protected]>2026-04-12 22:30:22 -0500
commit54d037548261c1546555a89a6a803b542a769880 (patch)
treec99bf489071d40e4acc0d99aae769abc8e614497 /Hutch/Views/Repositories/RepositorySettingsViewModel.swift
parent7a875798fea9321bad2636040d9be2c30bc0fa8f (diff)
downloadhutch-54d037548261c1546555a89a6a803b542a769880.tar.gz
hutch-54d037548261c1546555a89a6a803b542a769880.tar.bz2
hutch-54d037548261c1546555a89a6a803b542a769880.zip
feat: add repo settings management
Implements: https://todo.sr.ht/~ccleberg/hutch/42 Implements: https://todo.sr.ht/~ccleberg/hutch/43 Implements: https://todo.sr.ht/~ccleberg/hutch/44
Diffstat (limited to 'Hutch/Views/Repositories/RepositorySettingsViewModel.swift')
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsViewModel.swift320
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
+ }
}