diff options
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/Models/RepositorySummary.swift | 34 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileView.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileViewModel.swift | 11 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserRepositoriesView.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailView.swift | 37 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryListView.swift | 15 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryListViewModel.swift | 11 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryRowView.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySettingsView.swift | 226 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySettingsViewModel.swift | 320 | ||||
| -rw-r--r-- | HutchTests/RepositorySettingsViewModelTests.swift | 26 |
12 files changed, 473 insertions, 235 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 0a9a9b6..3a7f7cd 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -515,7 +515,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -532,7 +532,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.19.1; + MARKETING_VERSION = 2.19.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -552,7 +552,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.19.1; + MARKETING_VERSION = 2.19.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -632,7 +632,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -642,7 +642,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.19.1; + MARKETING_VERSION = 2.19.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -661,7 +661,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -671,7 +671,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.19.1; + MARKETING_VERSION = 2.19.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/Models/RepositorySummary.swift b/Hutch/Models/RepositorySummary.swift index f4ecc6e..3821aa2 100644 --- a/Hutch/Models/RepositorySummary.swift +++ b/Hutch/Models/RepositorySummary.swift @@ -55,3 +55,37 @@ struct RepositorySummary: Codable, Sendable, Identifiable, Hashable { self.head = try container.decodeIfPresent(Reference.self, forKey: .head) } } + +extension RepositorySummary { + var defaultBranchName: String? { + head?.name.replacingOccurrences(of: "refs/heads/", with: "") + } + + func updating( + name: String? = nil, + description: String? = nil, + visibility: Visibility? = nil, + updated: Date? = nil, + head: Reference? = nil + ) -> RepositorySummary { + RepositorySummary( + id: id, + rid: rid, + service: service, + name: name ?? self.name, + description: description ?? self.description, + visibility: visibility ?? self.visibility, + updated: updated ?? self.updated, + owner: owner, + head: head ?? self.head + ) + } + + static func displayBranchName(for reference: Reference) -> String { + displayBranchName(for: reference.name) + } + + static func displayBranchName(for referenceName: String) -> String { + referenceName.replacingOccurrences(of: "refs/heads/", with: "") + } +} diff --git a/Hutch/Views/Lookup/UserProfileView.swift b/Hutch/Views/Lookup/UserProfileView.swift index b694738..068e05f 100644 --- a/Hutch/Views/Lookup/UserProfileView.swift +++ b/Hutch/Views/Lookup/UserProfileView.swift @@ -119,7 +119,9 @@ struct UserProfileView: View { } else { ForEach(viewModel.repositories.prefix(4)) { repo in NavigationLink { - RepositoryDetailView(repository: repo) + RepositoryDetailView(repository: repo) { updatedRepository in + viewModel.updateRepository(updatedRepository) + } } label: { RepositoryRowView(repository: repo, buildStatus: .none) } diff --git a/Hutch/Views/Lookup/UserProfileViewModel.swift b/Hutch/Views/Lookup/UserProfileViewModel.swift index 56c2852..b88d36a 100644 --- a/Hutch/Views/Lookup/UserProfileViewModel.swift +++ b/Hutch/Views/Lookup/UserProfileViewModel.swift @@ -61,6 +61,17 @@ final class UserProfileViewModel { } } + func updateRepository(_ repository: RepositorySummary) { + guard let index = repositories.firstIndex(where: { $0.id == repository.id }) else { return } + repositories[index] = repository + repositories.sort { lhs, rhs in + if lhs.updated == rhs.updated { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return lhs.updated > rhs.updated + } + } + func loadTrackers() async { isLoadingTrackers = true trackersError = nil diff --git a/Hutch/Views/Lookup/UserRepositoriesView.swift b/Hutch/Views/Lookup/UserRepositoriesView.swift index 52d3c15..7aeea10 100644 --- a/Hutch/Views/Lookup/UserRepositoriesView.swift +++ b/Hutch/Views/Lookup/UserRepositoriesView.swift @@ -7,7 +7,9 @@ struct UserRepositoriesView: View { List { ForEach(viewModel.repositories) { repo in NavigationLink { - RepositoryDetailView(repository: repo) + RepositoryDetailView(repository: repo) { updatedRepository in + viewModel.updateRepository(updatedRepository) + } } label: { RepositoryRowView(repository: repo, buildStatus: .none) } diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift index b19e5b0..0031e47 100644 --- a/Hutch/Views/Repositories/RepositoryDetailView.swift +++ b/Hutch/Views/Repositories/RepositoryDetailView.swift @@ -1,7 +1,7 @@ import SwiftUI struct RepositoryDetailView: View { - var repository: RepositorySummary + let onRepositoryUpdated: ((RepositorySummary) -> Void)? var onDeleted: (() -> Void)? @Environment(AppState.self) private var appState @@ -10,22 +10,26 @@ struct RepositoryDetailView: View { @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary @State private var showSettings = false @State private var showACLs = false - @State private var displayName: String + @State private var currentRepository: RepositorySummary private var canManageRepository: Bool { guard let currentUser = appState.currentUser else { return false } - return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) + return normalizedUsername(currentUser.username) == normalizedUsername(currentRepository.owner.canonicalName) } - init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) { - self.repository = repository + init( + repository: RepositorySummary, + onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil, + onDeleted: (() -> Void)? = nil + ) { + self.onRepositoryUpdated = onRepositoryUpdated self.onDeleted = onDeleted - self._displayName = State(initialValue: repository.name) + self._currentRepository = State(initialValue: repository) } var body: some View { - if repository.service == .hg { - HgRepositoryDetailView(repository: repository, onDeleted: onDeleted) + if currentRepository.service == .hg { + HgRepositoryDetailView(repository: currentRepository, onDeleted: onDeleted) } else { Group { if let viewModel { @@ -34,11 +38,11 @@ struct RepositoryDetailView: View { SRHTLoadingStateView(message: "Loading repository…") } } - .navigationTitle(displayName) + .navigationTitle(currentRepository.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { - SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) { + SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) { Image(systemName: "square.and.arrow.up") } @@ -59,11 +63,12 @@ struct RepositoryDetailView: View { } .sheet(isPresented: $showSettings) { RepositorySettingsView( - repository: repository, + repository: currentRepository, branches: viewModel?.branches ?? [], client: appState.client, - onRenamed: { newName in - displayName = newName + onUpdated: { updatedRepository in + currentRepository = updatedRepository + onRepositoryUpdated?(updatedRepository) }, onDeleted: { dismiss() @@ -74,7 +79,7 @@ struct RepositoryDetailView: View { .sheet(isPresented: $showACLs) { NavigationStack { RepositoryACLView( - repository: repository, + repository: currentRepository, client: appState.client, showsDoneButton: true ) @@ -83,7 +88,7 @@ struct RepositoryDetailView: View { .task { if viewModel == nil { viewModel = RepositoryDetailViewModel( - repository: repository, + repository: currentRepository, client: appState.client ) } @@ -110,7 +115,7 @@ struct RepositoryDetailView: View { ReadmeView(viewModel: viewModel) case .tree: FileTreeView( - repository: repository, + repository: currentRepository, client: appState.client ) case .log: diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift index fb090b2..d176e4b 100644 --- a/Hutch/Views/Repositories/RepositoryListView.swift +++ b/Hutch/Views/Repositories/RepositoryListView.swift @@ -45,7 +45,13 @@ struct RepositoryListView: View { } )) { if let createdRepository { - RepositoryDetailView(repository: createdRepository) { + RepositoryDetailView( + repository: createdRepository, + onRepositoryUpdated: { updatedRepository in + self.createdRepository = updatedRepository + viewModel?.updateRepository(updatedRepository) + } + ) { viewModel?.removeRepository(id: createdRepository.id) } } @@ -139,7 +145,12 @@ struct RepositoryListView: View { } } .navigationDestination(for: RepositorySummary.self) { repo in - RepositoryDetailView(repository: repo) { + RepositoryDetailView( + repository: repo, + onRepositoryUpdated: { updatedRepository in + viewModel.updateRepository(updatedRepository) + } + ) { viewModel.removeRepository(id: repo.id) } } diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift index fd5bafe..7a65441 100644 --- a/Hutch/Views/Repositories/RepositoryListViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift @@ -208,6 +208,17 @@ final class RepositoryListViewModel { repositories.removeAll { $0.id == id } } + func updateRepository(_ repository: RepositorySummary) { + if let index = repositories.firstIndex(where: { $0.id == repository.id }) { + repositories[index] = repository + repositories.sort(by: repositorySortOrder) + } + + let updatedRepositories = repositoriesForSearchIndex + .filter { $0.id != repository.id } + [repository] + updateSearchIndex(with: updatedRepositories) + } + func createRepository( service: RepositoryCreationService, name: String, diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift index f791494..814ea76 100644 --- a/Hutch/Views/Repositories/RepositoryRowView.swift +++ b/Hutch/Views/Repositories/RepositoryRowView.swift @@ -41,8 +41,8 @@ struct RepositoryRowView: View { } HStack(spacing: 12) { - if let head = repository.head { - Label(head.name, systemImage: "arrow.triangle.branch") + if let defaultBranchName = repository.defaultBranchName { + Label(defaultBranchName, systemImage: "arrow.triangle.branch") .font(.caption) .foregroundStyle(.secondary) } diff --git a/Hutch/Views/Repositories/RepositorySettingsView.swift b/Hutch/Views/Repositories/RepositorySettingsView.swift index e6beedf..4736619 100644 --- a/Hutch/Views/Repositories/RepositorySettingsView.swift +++ b/Hutch/Views/Repositories/RepositorySettingsView.swift @@ -4,14 +4,13 @@ struct RepositorySettingsView: View { let repository: RepositorySummary let branches: [ReferenceDetail] let client: SRHTClient - let onRenamed: (String) -> Void + let onUpdated: (RepositorySummary) -> Void let onDeleted: () -> Void @Environment(\.dismiss) private var dismiss @State private var viewModel: RepositorySettingsViewModel? + @State private var showVisibilityConfirmation = false @State private var showDeleteConfirmation = false - @State private var showRenameConfirmation = false - @State private var saveResultAlert: SaveResultAlert? var body: some View { NavigationStack { @@ -32,12 +31,11 @@ struct RepositorySettingsView: View { } .task { if viewModel == nil { - let vm = RepositorySettingsViewModel( + viewModel = RepositorySettingsViewModel( repository: repository, branches: branches, client: client ) - viewModel = vm } } } @@ -47,37 +45,36 @@ struct RepositorySettingsView: View { @Bindable var vm = viewModel Form { - infoSection(viewModel) - renameSection(viewModel) - accessSection() + currentConfigurationSection(viewModel) + metadataSection(viewModel) + defaultBranchSection(viewModel) + visibilitySection(viewModel) deleteSection(viewModel) } .srhtErrorBanner(error: $vm.error) .alert( - "Rename repository to \(viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines))?", - isPresented: $showRenameConfirmation + visibilityConfirmationTitle(for: viewModel), + isPresented: $showVisibilityConfirmation ) { Button("Cancel", role: .cancel) { - // Alert dismissal is implicit; no additional action required. + viewModel.editedVisibility = viewModel.repository.visibility } - Button("Rename", role: .destructive) { + Button("Apply", role: .destructive) { Task { - await viewModel.rename() - if let newName = viewModel.updatedName { - onRenamed(newName) - dismiss() + if let updatedRepository = await viewModel.updateVisibility() { + onUpdated(updatedRepository) } } } } message: { - Text("This will change the repository URL. Existing clones will be redirected but links may break.") + Text(visibilityConfirmationMessage(for: viewModel)) } .alert( - "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?", + "Permanently delete \(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)?", isPresented: $showDeleteConfirmation ) { Button("Cancel", role: .cancel) { - // Alert dismissal is implicit; no additional action required. + // Alert dismissal is implicit. } Button("Delete", role: .destructive) { Task { @@ -91,115 +88,156 @@ struct RepositorySettingsView: View { } message: { Text("This cannot be undone.") } - .alert(item: $saveResultAlert) { alert in - Alert( - title: Text(alert.title), - message: Text(alert.message), - dismissButton: .default(Text("OK")) - ) - } } - // MARK: - Info Section - @ViewBuilder - private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View { - Section("Info") { - LabeledContent("Name") { - Text(repository.name) + private func currentConfigurationSection(_ viewModel: RepositorySettingsViewModel) -> some View { + Section("Current Configuration") { + LabeledContent("Repository") { + Text("\(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)") .font(.body.monospaced()) } - TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) - .lineLimit(3...6) + LabeledContent("Default Branch") { + Text(viewModel.currentDefaultBranchName) + .font(.body.monospaced()) + } - Picker("Visibility", selection: Bindable(viewModel).editedVisibility) { - Text("Public").tag(Visibility.public) - Text("Unlisted").tag(Visibility.unlisted) - Text("Private").tag(Visibility.private) + LabeledContent("Visibility") { + Text(repositoryVisibilityLabel(viewModel.repository.visibility)) } + } + } - if !viewModel.branches.isEmpty { - Picker("Default Branch", selection: Bindable(viewModel).editedHead) { - ForEach(viewModel.branches, id: \.name) { branch in - let name = branch.name.replacingOccurrences(of: "refs/heads/", with: "") - Text(name).tag(name) - } - } + @ViewBuilder + private func metadataSection(_ viewModel: RepositorySettingsViewModel) -> some View { + Section { + TextField("Repository name", text: Bindable(viewModel).editedName) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) + .lineLimit(2...4) + + if let metadataValidationMessage = viewModel.metadataValidationMessage { + Text(metadataValidationMessage) + .font(.caption) + .foregroundStyle(.red) + } else if viewModel.normalizedEditedName != viewModel.repository.name { + Text("Changing the repository name updates the repository URL.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Name and description stay pending until you save this section.") + .font(.caption) + .foregroundStyle(.secondary) } Button { Task { - let didSave = await viewModel.saveInfo() - saveResultAlert = SaveResultAlert( - title: didSave ? "Settings Updated" : "Couldn't Update Settings", - message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.") - ) + if let updatedRepository = await viewModel.saveMetadata() { + onUpdated(updatedRepository) + } } } label: { - if viewModel.isSavingInfo { + if viewModel.isSavingMetadata { ProgressView() .frame(maxWidth: .infinity) } else { - Text("Save Changes") + Text("Save Details") .frame(maxWidth: .infinity) } } - .disabled(viewModel.isSavingInfo) + .disabled( + viewModel.isMutating || + !viewModel.isMetadataDirty || + viewModel.metadataValidationMessage != nil + ) + } header: { + Text("Repository Details") } } - // MARK: - Rename Section - @ViewBuilder - private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View { + private func defaultBranchSection(_ viewModel: RepositorySettingsViewModel) -> some View { Section { - TextField("New repository name", text: Bindable(viewModel).editedName) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) + LabeledContent("Current") { + Text(viewModel.currentDefaultBranchName) + .font(.body.monospaced()) + } - Text("This will change the repository URL. Existing clones will be redirected but links may break.") - .font(.caption) - .foregroundStyle(.secondary) + if viewModel.branches.isEmpty { + Text("This repository doesn't have any branches yet.") + .foregroundStyle(.secondary) + } else { + Picker("Branch", selection: Bindable(viewModel).editedHead) { + ForEach(viewModel.availableBranchNames, id: \.self) { branch in + Text(branch) + .font(.body.monospaced()) + .tag(branch) + } + } - Button { - showRenameConfirmation = true - } label: { - if viewModel.isRenaming { - ProgressView() - .frame(maxWidth: .infinity) - } else { - Text("Rename Repository") - .frame(maxWidth: .infinity) + Text("Changes stay pending until you set the new default branch.") + .font(.caption) + .foregroundStyle(.secondary) + + Button { + Task { + if let updatedRepository = await viewModel.saveDefaultBranch() { + onUpdated(updatedRepository) + } + } + } label: { + if viewModel.isSavingDefaultBranch { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Set Default Branch") + .frame(maxWidth: .infinity) + } } + .disabled( + viewModel.isMutating || + !viewModel.isDefaultBranchDirty || + viewModel.defaultBranchValidationMessage != nil + ) } - .disabled(viewModel.isRenaming || viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } header: { - Text("Rename") + Text("Default Branch") } } - // MARK: - Access Section - @ViewBuilder - private func accessSection() -> some View { + private func visibilitySection(_ viewModel: RepositorySettingsViewModel) -> some View { Section { - NavigationLink { - RepositoryACLView(repository: repository, client: client, showsDoneButton: false) - } label: { - Label("Manage Access", systemImage: "person.2") + Picker("Visibility", selection: Bindable(viewModel).editedVisibility) { + Text("Public").tag(Visibility.public) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.private) } - Text("Review and update repository access without leaving settings.") + Text("Visibility changes apply immediately after you confirm them.") .font(.caption) .foregroundStyle(.secondary) + + Button { + showVisibilityConfirmation = true + } label: { + if viewModel.isUpdatingVisibility { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Apply Visibility Change") + .frame(maxWidth: .infinity) + } + } + .disabled(viewModel.isMutating || !viewModel.isVisibilityDirty) } header: { - Text("Access") + Text("Sensitive Settings") } } - // MARK: - Delete Section - @ViewBuilder private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> some View { Section { @@ -214,14 +252,24 @@ struct RepositorySettingsView: View { .frame(maxWidth: .infinity) } } - .disabled(viewModel.isDeleting) + .disabled(viewModel.isMutating) + } header: { + Text("Danger Zone") } } - private struct SaveResultAlert: Identifiable { - let title: String - let message: String + private func visibilityConfirmationTitle(for viewModel: RepositorySettingsViewModel) -> String { + "Change visibility to \(repositoryVisibilityLabel(viewModel.editedVisibility))?" + } - var id: String { "\(title)-\(message)" } + private func visibilityConfirmationMessage(for viewModel: RepositorySettingsViewModel) -> String { + switch viewModel.editedVisibility { + case .public: + "Anyone will be able to find and view this repository." + case .unlisted: + "People with the link can view this repository, but it won't appear in public listings." + case .private: + "Only people with explicit access will be able to view this repository." + } } } 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 + } } diff --git a/HutchTests/RepositorySettingsViewModelTests.swift b/HutchTests/RepositorySettingsViewModelTests.swift index 5b06539..3b76da1 100644 --- a/HutchTests/RepositorySettingsViewModelTests.swift +++ b/HutchTests/RepositorySettingsViewModelTests.swift @@ -89,6 +89,32 @@ struct RepositorySettingsViewModelTests { #expect(viewModel.selectedHeadReferenceForSave() == nil) } + @Test + @MainActor + func metadataValidationRejectsEmptyOrInvalidNames() { + #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "") == "Enter a repository name.") + #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "repo/name") == "Repository names can't contain '/'.") + #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "repo name") == "Repository names can't contain spaces.") + } + + @Test + @MainActor + func metadataDirtyStateTracksNormalizedInputs() { + let viewModel = RepositorySettingsViewModel( + repository: makeRepository(headName: "refs/heads/main"), + branches: [ReferenceDetail(name: "refs/heads/main", target: nil, date: nil)], + client: SRHTClient(token: "test-token") + ) + + #expect(viewModel.isMetadataDirty == false) + viewModel.editedDescription = " updated " + #expect(viewModel.isMetadataDirty) + + viewModel.editedDescription = "desc" + viewModel.editedName = " repo " + #expect(viewModel.isMetadataDirty == false) + } + @MainActor private func makeRepository(headName: String?) -> RepositorySummary { RepositorySummary( |
