Commit e8d7a07087
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | ||
| 515 | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 44; | |
| 518 | CURRENT_PROJECT_VERSION = 45; | |
| 519 | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | 520 | ENABLE_PREVIEWS = YES; |
| 521 | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | ||
| 532 | 532 | "$(inherited)", |
| 533 | 533 | "@executable_path/Frameworks", |
| 534 | 534 | ); |
| 535 | MARKETING_VERSION = 2.19.1; | |
| 535 | MARKETING_VERSION = 2.19.2; | |
| 536 | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | ||
| 552 | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 44; | |
| 555 | CURRENT_PROJECT_VERSION = 45; | |
| 556 | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | 557 | ENABLE_PREVIEWS = YES; |
| 558 | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | ||
| 569 | 569 | "$(inherited)", |
| 570 | 570 | "@executable_path/Frameworks", |
| 571 | 571 | ); |
| 572 | MARKETING_VERSION = 2.19.1; | |
| 572 | MARKETING_VERSION = 2.19.2; | |
| 573 | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | ||
| 632 | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 44; | |
| 635 | CURRENT_PROJECT_VERSION = 45; | |
| 636 | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | "@executable_path/Frameworks", |
| 643 | 643 | "@executable_path/../../Frameworks", |
| 644 | 644 | ); |
| 645 | MARKETING_VERSION = 2.19.1; | |
| 645 | MARKETING_VERSION = 2.19.2; | |
| 646 | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | ||
| 661 | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 44; | |
| 664 | CURRENT_PROJECT_VERSION = 45; | |
| 665 | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | ||
| 671 | 671 | "@executable_path/Frameworks", |
| 672 | 672 | "@executable_path/../../Frameworks", |
| 673 | 673 | ); |
| 674 | MARKETING_VERSION = 2.19.1; | |
| 674 | MARKETING_VERSION = 2.19.2; | |
| 675 | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 677 | SKIP_INSTALL = YES; |
Hutch/Models/RepositorySummary.swift +34
| @@ -55,3 +55,37 @@ struct RepositorySummary: Codable, Sendable, Identifiable, Hashable { | ||
| 55 | 55 | self.head = try container.decodeIfPresent(Reference.self, forKey: .head) |
| 56 | 56 | } |
| 57 | 57 | } |
| 58 | ||
| 59 | extension RepositorySummary { | |
| 60 | var defaultBranchName: String? { | |
| 61 | head?.name.replacingOccurrences(of: "refs/heads/", with: "") | |
| 62 | } | |
| 63 | ||
| 64 | func updating( | |
| 65 | name: String? = nil, | |
| 66 | description: String? = nil, | |
| 67 | visibility: Visibility? = nil, | |
| 68 | updated: Date? = nil, | |
| 69 | head: Reference? = nil | |
| 70 | ) -> RepositorySummary { | |
| 71 | RepositorySummary( | |
| 72 | id: id, | |
| 73 | rid: rid, | |
| 74 | service: service, | |
| 75 | name: name ?? self.name, | |
| 76 | description: description ?? self.description, | |
| 77 | visibility: visibility ?? self.visibility, | |
| 78 | updated: updated ?? self.updated, | |
| 79 | owner: owner, | |
| 80 | head: head ?? self.head | |
| 81 | ) | |
| 82 | } | |
| 83 | ||
| 84 | static func displayBranchName(for reference: Reference) -> String { | |
| 85 | displayBranchName(for: reference.name) | |
| 86 | } | |
| 87 | ||
| 88 | static func displayBranchName(for referenceName: String) -> String { | |
| 89 | referenceName.replacingOccurrences(of: "refs/heads/", with: "") | |
| 90 | } | |
| 91 | } | |
Hutch/Views/Lookup/UserProfileView.swift +3 −1
| @@ -119,7 +119,9 @@ struct UserProfileView: View { | ||
| 119 | 119 | } else { |
| 120 | 120 | ForEach(viewModel.repositories.prefix(4)) { repo in |
| 121 | 121 | NavigationLink { |
| 122 | RepositoryDetailView(repository: repo) | |
| 122 | RepositoryDetailView(repository: repo) { updatedRepository in | |
| 123 | viewModel.updateRepository(updatedRepository) | |
| 124 | } | |
| 123 | 125 | } label: { |
| 124 | 126 | RepositoryRowView(repository: repo, buildStatus: .none) |
| 125 | 127 | } |
Hutch/Views/Lookup/UserProfileViewModel.swift +11
| @@ -61,6 +61,17 @@ final class UserProfileViewModel { | ||
| 61 | 61 | } |
| 62 | 62 | } |
| 63 | 63 | |
| 64 | func updateRepository(_ repository: RepositorySummary) { | |
| 65 | guard let index = repositories.firstIndex(where: { $0.id == repository.id }) else { return } | |
| 66 | repositories[index] = repository | |
| 67 | repositories.sort { lhs, rhs in | |
| 68 | if lhs.updated == rhs.updated { | |
| 69 | return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending | |
| 70 | } | |
| 71 | return lhs.updated > rhs.updated | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 64 | 75 | func loadTrackers() async { |
| 65 | 76 | isLoadingTrackers = true |
| 66 | 77 | trackersError = nil |
Hutch/Views/Lookup/UserRepositoriesView.swift +3 −1
| @@ -7,7 +7,9 @@ struct UserRepositoriesView: View { | ||
| 7 | 7 | List { |
| 8 | 8 | ForEach(viewModel.repositories) { repo in |
| 9 | 9 | NavigationLink { |
| 10 | RepositoryDetailView(repository: repo) | |
| 10 | RepositoryDetailView(repository: repo) { updatedRepository in | |
| 11 | viewModel.updateRepository(updatedRepository) | |
| 12 | } | |
| 11 | 13 | } label: { |
| 12 | 14 | RepositoryRowView(repository: repo, buildStatus: .none) |
| 13 | 15 | } |
Hutch/Views/Repositories/RepositoryDetailView.swift +21 −16
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | 3 | struct RepositoryDetailView: View { |
| 4 | var repository: RepositorySummary | |
| 4 | let onRepositoryUpdated: ((RepositorySummary) -> Void)? | |
| 5 | 5 | var onDeleted: (() -> Void)? |
| 6 | 6 | |
| 7 | 7 | @Environment(AppState.self) private var appState |
| @@ -10,22 +10,26 @@ struct RepositoryDetailView: View { | ||
| 10 | 10 | @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary |
| 11 | 11 | @State private var showSettings = false |
| 12 | 12 | @State private var showACLs = false |
| 13 | @State private var displayName: String | |
| 13 | @State private var currentRepository: RepositorySummary | |
| 14 | 14 | |
| 15 | 15 | private var canManageRepository: Bool { |
| 16 | 16 | guard let currentUser = appState.currentUser else { return false } |
| 17 | return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) | |
| 17 | return normalizedUsername(currentUser.username) == normalizedUsername(currentRepository.owner.canonicalName) | |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) { | |
| 21 | self.repository = repository | |
| 20 | init( | |
| 21 | repository: RepositorySummary, | |
| 22 | onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil, | |
| 23 | onDeleted: (() -> Void)? = nil | |
| 24 | ) { | |
| 25 | self.onRepositoryUpdated = onRepositoryUpdated | |
| 22 | 26 | self.onDeleted = onDeleted |
| 23 | self._displayName = State(initialValue: repository.name) | |
| 27 | self._currentRepository = State(initialValue: repository) | |
| 24 | 28 | } |
| 25 | 29 | |
| 26 | 30 | var body: some View { |
| 27 | if repository.service == .hg { | |
| 28 | HgRepositoryDetailView(repository: repository, onDeleted: onDeleted) | |
| 31 | if currentRepository.service == .hg { | |
| 32 | HgRepositoryDetailView(repository: currentRepository, onDeleted: onDeleted) | |
| 29 | 33 | } else { |
| 30 | 34 | Group { |
| 31 | 35 | if let viewModel { |
| @@ -34,11 +38,11 @@ struct RepositoryDetailView: View { | ||
| 34 | 38 | SRHTLoadingStateView(message: "Loading repository…") |
| 35 | 39 | } |
| 36 | 40 | } |
| 37 | .navigationTitle(displayName) | |
| 41 | .navigationTitle(currentRepository.name) | |
| 38 | 42 | .navigationBarTitleDisplayMode(.inline) |
| 39 | 43 | .toolbar { |
| 40 | 44 | ToolbarItemGroup(placement: .topBarTrailing) { |
| 41 | SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) { | |
| 45 | SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) { | |
| 42 | 46 | Image(systemName: "square.and.arrow.up") |
| 43 | 47 | } |
| 44 | 48 | |
| @@ -59,11 +63,12 @@ struct RepositoryDetailView: View { | ||
| 59 | 63 | } |
| 60 | 64 | .sheet(isPresented: $showSettings) { |
| 61 | 65 | RepositorySettingsView( |
| 62 | repository: repository, | |
| 66 | repository: currentRepository, | |
| 63 | 67 | branches: viewModel?.branches ?? [], |
| 64 | 68 | client: appState.client, |
| 65 | onRenamed: { newName in | |
| 66 | displayName = newName | |
| 69 | onUpdated: { updatedRepository in | |
| 70 | currentRepository = updatedRepository | |
| 71 | onRepositoryUpdated?(updatedRepository) | |
| 67 | 72 | }, |
| 68 | 73 | onDeleted: { |
| 69 | 74 | dismiss() |
| @@ -74,7 +79,7 @@ struct RepositoryDetailView: View { | ||
| 74 | 79 | .sheet(isPresented: $showACLs) { |
| 75 | 80 | NavigationStack { |
| 76 | 81 | RepositoryACLView( |
| 77 | repository: repository, | |
| 82 | repository: currentRepository, | |
| 78 | 83 | client: appState.client, |
| 79 | 84 | showsDoneButton: true |
| 80 | 85 | ) |
| @@ -83,7 +88,7 @@ struct RepositoryDetailView: View { | ||
| 83 | 88 | .task { |
| 84 | 89 | if viewModel == nil { |
| 85 | 90 | viewModel = RepositoryDetailViewModel( |
| 86 | repository: repository, | |
| 91 | repository: currentRepository, | |
| 87 | 92 | client: appState.client |
| 88 | 93 | ) |
| 89 | 94 | } |
| @@ -110,7 +115,7 @@ struct RepositoryDetailView: View { | ||
| 110 | 115 | ReadmeView(viewModel: viewModel) |
| 111 | 116 | case .tree: |
| 112 | 117 | FileTreeView( |
| 113 | repository: repository, | |
| 118 | repository: currentRepository, | |
| 114 | 119 | client: appState.client |
| 115 | 120 | ) |
| 116 | 121 | case .log: |
Hutch/Views/Repositories/RepositoryListView.swift +13 −2
| @@ -45,7 +45,13 @@ struct RepositoryListView: View { | ||
| 45 | 45 | } |
| 46 | 46 | )) { |
| 47 | 47 | if let createdRepository { |
| 48 | RepositoryDetailView(repository: createdRepository) { | |
| 48 | RepositoryDetailView( | |
| 49 | repository: createdRepository, | |
| 50 | onRepositoryUpdated: { updatedRepository in | |
| 51 | self.createdRepository = updatedRepository | |
| 52 | viewModel?.updateRepository(updatedRepository) | |
| 53 | } | |
| 54 | ) { | |
| 49 | 55 | viewModel?.removeRepository(id: createdRepository.id) |
| 50 | 56 | } |
| 51 | 57 | } |
| @@ -139,7 +145,12 @@ struct RepositoryListView: View { | ||
| 139 | 145 | } |
| 140 | 146 | } |
| 141 | 147 | .navigationDestination(for: RepositorySummary.self) { repo in |
| 142 | RepositoryDetailView(repository: repo) { | |
| 148 | RepositoryDetailView( | |
| 149 | repository: repo, | |
| 150 | onRepositoryUpdated: { updatedRepository in | |
| 151 | viewModel.updateRepository(updatedRepository) | |
| 152 | } | |
| 153 | ) { | |
| 143 | 154 | viewModel.removeRepository(id: repo.id) |
| 144 | 155 | } |
| 145 | 156 | } |
Hutch/Views/Repositories/RepositoryListViewModel.swift +11
| @@ -208,6 +208,17 @@ final class RepositoryListViewModel { | ||
| 208 | 208 | repositories.removeAll { $0.id == id } |
| 209 | 209 | } |
| 210 | 210 | |
| 211 | func updateRepository(_ repository: RepositorySummary) { | |
| 212 | if let index = repositories.firstIndex(where: { $0.id == repository.id }) { | |
| 213 | repositories[index] = repository | |
| 214 | repositories.sort(by: repositorySortOrder) | |
| 215 | } | |
| 216 | ||
| 217 | let updatedRepositories = repositoriesForSearchIndex | |
| 218 | .filter { $0.id != repository.id } + [repository] | |
| 219 | updateSearchIndex(with: updatedRepositories) | |
| 220 | } | |
| 221 | ||
| 211 | 222 | func createRepository( |
| 212 | 223 | service: RepositoryCreationService, |
| 213 | 224 | name: String, |
Hutch/Views/Repositories/RepositoryRowView.swift +2 −2
| @@ -41,8 +41,8 @@ struct RepositoryRowView: View { | ||
| 41 | 41 | } |
| 42 | 42 | |
| 43 | 43 | HStack(spacing: 12) { |
| 44 | if let head = repository.head { | |
| 45 | Label(head.name, systemImage: "arrow.triangle.branch") | |
| 44 | if let defaultBranchName = repository.defaultBranchName { | |
| 45 | Label(defaultBranchName, systemImage: "arrow.triangle.branch") | |
| 46 | 46 | .font(.caption) |
| 47 | 47 | .foregroundStyle(.secondary) |
| 48 | 48 | } |
Hutch/Views/Repositories/RepositorySettingsView.swift +137 −89
| @@ -4,14 +4,13 @@ struct RepositorySettingsView: View { | ||
| 4 | 4 | let repository: RepositorySummary |
| 5 | 5 | let branches: [ReferenceDetail] |
| 6 | 6 | let client: SRHTClient |
| 7 | let onRenamed: (String) -> Void | |
| 7 | let onUpdated: (RepositorySummary) -> Void | |
| 8 | 8 | let onDeleted: () -> Void |
| 9 | 9 | |
| 10 | 10 | @Environment(\.dismiss) private var dismiss |
| 11 | 11 | @State private var viewModel: RepositorySettingsViewModel? |
| 12 | @State private var showVisibilityConfirmation = false | |
| 12 | 13 | @State private var showDeleteConfirmation = false |
| 13 | @State private var showRenameConfirmation = false | |
| 14 | @State private var saveResultAlert: SaveResultAlert? | |
| 15 | 14 | |
| 16 | 15 | var body: some View { |
| 17 | 16 | NavigationStack { |
| @@ -32,12 +31,11 @@ struct RepositorySettingsView: View { | ||
| 32 | 31 | } |
| 33 | 32 | .task { |
| 34 | 33 | if viewModel == nil { |
| 35 | let vm = RepositorySettingsViewModel( | |
| 34 | viewModel = RepositorySettingsViewModel( | |
| 36 | 35 | repository: repository, |
| 37 | 36 | branches: branches, |
| 38 | 37 | client: client |
| 39 | 38 | ) |
| 40 | viewModel = vm | |
| 41 | 39 | } |
| 42 | 40 | } |
| 43 | 41 | } |
| @@ -47,37 +45,36 @@ struct RepositorySettingsView: View { | ||
| 47 | 45 | @Bindable var vm = viewModel |
| 48 | 46 | |
| 49 | 47 | Form { |
| 50 | infoSection(viewModel) | |
| 51 | renameSection(viewModel) | |
| 52 | accessSection() | |
| 48 | currentConfigurationSection(viewModel) | |
| 49 | metadataSection(viewModel) | |
| 50 | defaultBranchSection(viewModel) | |
| 51 | visibilitySection(viewModel) | |
| 53 | 52 | deleteSection(viewModel) |
| 54 | 53 | } |
| 55 | 54 | .srhtErrorBanner(error: $vm.error) |
| 56 | 55 | .alert( |
| 57 | "Rename repository to \(viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines))?", | |
| 58 | isPresented: $showRenameConfirmation | |
| 56 | visibilityConfirmationTitle(for: viewModel), | |
| 57 | isPresented: $showVisibilityConfirmation | |
| 59 | 58 | ) { |
| 60 | 59 | Button("Cancel", role: .cancel) { |
| 61 | // Alert dismissal is implicit; no additional action required. | |
| 60 | viewModel.editedVisibility = viewModel.repository.visibility | |
| 62 | 61 | } |
| 63 | Button("Rename", role: .destructive) { | |
| 62 | Button("Apply", role: .destructive) { | |
| 64 | 63 | Task { |
| 65 | await viewModel.rename() | |
| 66 | if let newName = viewModel.updatedName { | |
| 67 | onRenamed(newName) | |
| 68 | dismiss() | |
| 64 | if let updatedRepository = await viewModel.updateVisibility() { | |
| 65 | onUpdated(updatedRepository) | |
| 69 | 66 | } |
| 70 | 67 | } |
| 71 | 68 | } |
| 72 | 69 | } message: { |
| 73 | Text("This will change the repository URL. Existing clones will be redirected but links may break.") | |
| 70 | Text(visibilityConfirmationMessage(for: viewModel)) | |
| 74 | 71 | } |
| 75 | 72 | .alert( |
| 76 | "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?", | |
| 73 | "Permanently delete \(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)?", | |
| 77 | 74 | isPresented: $showDeleteConfirmation |
| 78 | 75 | ) { |
| 79 | 76 | Button("Cancel", role: .cancel) { |
| 80 | // Alert dismissal is implicit; no additional action required. | |
| 77 | // Alert dismissal is implicit. | |
| 81 | 78 | } |
| 82 | 79 | Button("Delete", role: .destructive) { |
| 83 | 80 | Task { |
| @@ -91,115 +88,156 @@ struct RepositorySettingsView: View { | ||
| 91 | 88 | } message: { |
| 92 | 89 | Text("This cannot be undone.") |
| 93 | 90 | } |
| 94 | .alert(item: $saveResultAlert) { alert in | |
| 95 | Alert( | |
| 96 | title: Text(alert.title), | |
| 97 | message: Text(alert.message), | |
| 98 | dismissButton: .default(Text("OK")) | |
| 99 | ) | |
| 100 | } | |
| 101 | 91 | } |
| 102 | 92 | |
| 103 | // MARK: - Info Section | |
| 104 | ||
| 105 | 93 | @ViewBuilder |
| 106 | private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View { | |
| 107 | Section("Info") { | |
| 108 | LabeledContent("Name") { | |
| 109 | Text(repository.name) | |
| 94 | private func currentConfigurationSection(_ viewModel: RepositorySettingsViewModel) -> some View { | |
| 95 | Section("Current Configuration") { | |
| 96 | LabeledContent("Repository") { | |
| 97 | Text("\(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)") | |
| 110 | 98 | .font(.body.monospaced()) |
| 111 | 99 | } |
| 112 | 100 | |
| 113 | TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) | |
| 114 | .lineLimit(3...6) | |
| 101 | LabeledContent("Default Branch") { | |
| 102 | Text(viewModel.currentDefaultBranchName) | |
| 103 | .font(.body.monospaced()) | |
| 104 | } | |
| 115 | 105 | |
| 116 | Picker("Visibility", selection: Bindable(viewModel).editedVisibility) { | |
| 117 | Text("Public").tag(Visibility.public) | |
| 118 | Text("Unlisted").tag(Visibility.unlisted) | |
| 119 | Text("Private").tag(Visibility.private) | |
| 106 | LabeledContent("Visibility") { | |
| 107 | Text(repositoryVisibilityLabel(viewModel.repository.visibility)) | |
| 120 | 108 | } |
| 109 | } | |
| 110 | } | |
| 121 | 111 | |
| 122 | if !viewModel.branches.isEmpty { | |
| 123 | Picker("Default Branch", selection: Bindable(viewModel).editedHead) { | |
| 124 | ForEach(viewModel.branches, id: \.name) { branch in | |
| 125 | let name = branch.name.replacingOccurrences(of: "refs/heads/", with: "") | |
| 126 | Text(name).tag(name) | |
| 127 | } | |
| 128 | } | |
| 112 | @ViewBuilder | |
| 113 | private func metadataSection(_ viewModel: RepositorySettingsViewModel) -> some View { | |
| 114 | Section { | |
| 115 | TextField("Repository name", text: Bindable(viewModel).editedName) | |
| 116 | .autocorrectionDisabled() | |
| 117 | .textInputAutocapitalization(.never) | |
| 118 | ||
| 119 | TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) | |
| 120 | .lineLimit(2...4) | |
| 121 | ||
| 122 | if let metadataValidationMessage = viewModel.metadataValidationMessage { | |
| 123 | Text(metadataValidationMessage) | |
| 124 | .font(.caption) | |
| 125 | .foregroundStyle(.red) | |
| 126 | } else if viewModel.normalizedEditedName != viewModel.repository.name { | |
| 127 | Text("Changing the repository name updates the repository URL.") | |
| 128 | .font(.caption) | |
| 129 | .foregroundStyle(.secondary) | |
| 130 | } else { | |
| 131 | Text("Name and description stay pending until you save this section.") | |
| 132 | .font(.caption) | |
| 133 | .foregroundStyle(.secondary) | |
| 129 | 134 | } |
| 130 | 135 | |
| 131 | 136 | Button { |
| 132 | 137 | Task { |
| 133 | let didSave = await viewModel.saveInfo() | |
| 134 | saveResultAlert = SaveResultAlert( | |
| 135 | title: didSave ? "Settings Updated" : "Couldn't Update Settings", | |
| 136 | message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.") | |
| 137 | ) | |
| 138 | if let updatedRepository = await viewModel.saveMetadata() { | |
| 139 | onUpdated(updatedRepository) | |
| 140 | } | |
| 138 | 141 | } |
| 139 | 142 | } label: { |
| 140 | if viewModel.isSavingInfo { | |
| 143 | if viewModel.isSavingMetadata { | |
| 141 | 144 | ProgressView() |
| 142 | 145 | .frame(maxWidth: .infinity) |
| 143 | 146 | } else { |
| 144 | Text("Save Changes") | |
| 147 | Text("Save Details") | |
| 145 | 148 | .frame(maxWidth: .infinity) |
| 146 | 149 | } |
| 147 | 150 | } |
| 148 | .disabled(viewModel.isSavingInfo) | |
| 151 | .disabled( | |
| 152 | viewModel.isMutating || | |
| 153 | !viewModel.isMetadataDirty || | |
| 154 | viewModel.metadataValidationMessage != nil | |
| 155 | ) | |
| 156 | } header: { | |
| 157 | Text("Repository Details") | |
| 149 | 158 | } |
| 150 | 159 | } |
| 151 | 160 | |
| 152 | // MARK: - Rename Section | |
| 153 | ||
| 154 | 161 | @ViewBuilder |
| 155 | private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View { | |
| 162 | private func defaultBranchSection(_ viewModel: RepositorySettingsViewModel) -> some View { | |
| 156 | 163 | Section { |
| 157 | TextField("New repository name", text: Bindable(viewModel).editedName) | |
| 158 | .autocorrectionDisabled() | |
| 159 | .textInputAutocapitalization(.never) | |
| 164 | LabeledContent("Current") { | |
| 165 | Text(viewModel.currentDefaultBranchName) | |
| 166 | .font(.body.monospaced()) | |
| 167 | } | |
| 160 | 168 | |
| 161 | Text("This will change the repository URL. Existing clones will be redirected but links may break.") | |
| 162 | .font(.caption) | |
| 163 | .foregroundStyle(.secondary) | |
| 169 | if viewModel.branches.isEmpty { | |
| 170 | Text("This repository doesn't have any branches yet.") | |
| 171 | .foregroundStyle(.secondary) | |
| 172 | } else { | |
| 173 | Picker("Branch", selection: Bindable(viewModel).editedHead) { | |
| 174 | ForEach(viewModel.availableBranchNames, id: \.self) { branch in | |
| 175 | Text(branch) | |
| 176 | .font(.body.monospaced()) | |
| 177 | .tag(branch) | |
| 178 | } | |
| 179 | } | |
| 164 | 180 | |
| 165 | Button { | |
| 166 | showRenameConfirmation = true | |
| 167 | } label: { | |
| 168 | if viewModel.isRenaming { | |
| 169 | ProgressView() | |
| 170 | .frame(maxWidth: .infinity) | |
| 171 | } else { | |
| 172 | Text("Rename Repository") | |
| 173 | .frame(maxWidth: .infinity) | |
| 181 | Text("Changes stay pending until you set the new default branch.") | |
| 182 | .font(.caption) | |
| 183 | .foregroundStyle(.secondary) | |
| 184 | ||
| 185 | Button { | |
| 186 | Task { | |
| 187 | if let updatedRepository = await viewModel.saveDefaultBranch() { | |
| 188 | onUpdated(updatedRepository) | |
| 189 | } | |
| 190 | } | |
| 191 | } label: { | |
| 192 | if viewModel.isSavingDefaultBranch { | |
| 193 | ProgressView() | |
| 194 | .frame(maxWidth: .infinity) | |
| 195 | } else { | |
| 196 | Text("Set Default Branch") | |
| 197 | .frame(maxWidth: .infinity) | |
| 198 | } | |
| 174 | 199 | } |
| 200 | .disabled( | |
| 201 | viewModel.isMutating || | |
| 202 | !viewModel.isDefaultBranchDirty || | |
| 203 | viewModel.defaultBranchValidationMessage != nil | |
| 204 | ) | |
| 175 | 205 | } |
| 176 | .disabled(viewModel.isRenaming || viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 177 | 206 | } header: { |
| 178 | Text("Rename") | |
| 207 | Text("Default Branch") | |
| 179 | 208 | } |
| 180 | 209 | } |
| 181 | 210 | |
| 182 | // MARK: - Access Section | |
| 183 | ||
| 184 | 211 | @ViewBuilder |
| 185 | private func accessSection() -> some View { | |
| 212 | private func visibilitySection(_ viewModel: RepositorySettingsViewModel) -> some View { | |
| 186 | 213 | Section { |
| 187 | NavigationLink { | |
| 188 | RepositoryACLView(repository: repository, client: client, showsDoneButton: false) | |
| 189 | } label: { | |
| 190 | Label("Manage Access", systemImage: "person.2") | |
| 214 | Picker("Visibility", selection: Bindable(viewModel).editedVisibility) { | |
| 215 | Text("Public").tag(Visibility.public) | |
| 216 | Text("Unlisted").tag(Visibility.unlisted) | |
| 217 | Text("Private").tag(Visibility.private) | |
| 191 | 218 | } |
| 192 | 219 | |
| 193 | Text("Review and update repository access without leaving settings.") | |
| 220 | Text("Visibility changes apply immediately after you confirm them.") | |
| 194 | 221 | .font(.caption) |
| 195 | 222 | .foregroundStyle(.secondary) |
| 223 | ||
| 224 | Button { | |
| 225 | showVisibilityConfirmation = true | |
| 226 | } label: { | |
| 227 | if viewModel.isUpdatingVisibility { | |
| 228 | ProgressView() | |
| 229 | .frame(maxWidth: .infinity) | |
| 230 | } else { | |
| 231 | Text("Apply Visibility Change") | |
| 232 | .frame(maxWidth: .infinity) | |
| 233 | } | |
| 234 | } | |
| 235 | .disabled(viewModel.isMutating || !viewModel.isVisibilityDirty) | |
| 196 | 236 | } header: { |
| 197 | Text("Access") | |
| 237 | Text("Sensitive Settings") | |
| 198 | 238 | } |
| 199 | 239 | } |
| 200 | 240 | |
| 201 | // MARK: - Delete Section | |
| 202 | ||
| 203 | 241 | @ViewBuilder |
| 204 | 242 | private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> some View { |
| 205 | 243 | Section { |
| @@ -214,14 +252,24 @@ struct RepositorySettingsView: View { | ||
| 214 | 252 | .frame(maxWidth: .infinity) |
| 215 | 253 | } |
| 216 | 254 | } |
| 217 | .disabled(viewModel.isDeleting) | |
| 255 | .disabled(viewModel.isMutating) | |
| 256 | } header: { | |
| 257 | Text("Danger Zone") | |
| 218 | 258 | } |
| 219 | 259 | } |
| 220 | 260 | |
| 221 | private struct SaveResultAlert: Identifiable { | |
| 222 | let title: String | |
| 223 | let message: String | |
| 261 | private func visibilityConfirmationTitle(for viewModel: RepositorySettingsViewModel) -> String { | |
| 262 | "Change visibility to \(repositoryVisibilityLabel(viewModel.editedVisibility))?" | |
| 263 | } | |
| 224 | 264 | |
| 225 | var id: String { "\(title)-\(message)" } | |
| 265 | private func visibilityConfirmationMessage(for viewModel: RepositorySettingsViewModel) -> String { | |
| 266 | switch viewModel.editedVisibility { | |
| 267 | case .public: | |
| 268 | "Anyone will be able to find and view this repository." | |
| 269 | case .unlisted: | |
| 270 | "People with the link can view this repository, but it won't appear in public listings." | |
| 271 | case .private: | |
| 272 | "Only people with explicit access will be able to view this repository." | |
| 273 | } | |
| 226 | 274 | } |
| 227 | 275 | } |
Hutch/Views/Repositories/RepositorySettingsViewModel.swift +204 −116
| @@ -1,72 +1,119 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | // MARK: - Response types | |
| 4 | ||
| 5 | private struct UpdateRepoResponse: Decodable, Sendable { | |
| 6 | let updateRepository: UpdatedRepo | |
| 7 | } | |
| 8 | ||
| 9 | private struct UpdateRepoInfoResponse: Decodable, Sendable { | |
| 10 | let updateRepository: UpdatedRepoInfo | |
| 3 | private struct UpdateRepositoryResponse: Decodable, Sendable { | |
| 4 | let updateRepository: UpdatedRepositoryPayload | |
| 11 | 5 | } |
| 12 | 6 | |
| 13 | private struct UpdatedRepo: Decodable, Sendable { | |
| 14 | let id: Int? | |
| 15 | let rid: String? | |
| 7 | private struct UpdatedRepositoryPayload: Decodable, Sendable { | |
| 8 | let id: Int | |
| 9 | let rid: String | |
| 16 | 10 | let name: String |
| 17 | 11 | let description: String? |
| 18 | let visibility: Visibility? | |
| 19 | } | |
| 12 | let visibility: Visibility | |
| 13 | let updated: Date | |
| 14 | let head: Reference? | |
| 20 | 15 | |
| 21 | private struct UpdatedRepoInfo: Decodable, Sendable { | |
| 22 | let id: Int | |
| 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 | id: id, | |
| 24 | rid: rid, | |
| 25 | service: service, | |
| 26 | name: name, | |
| 27 | description: description, | |
| 28 | visibility: visibility, | |
| 29 | updated: updated, | |
| 30 | owner: owner, | |
| 31 | head: head | |
| 32 | ) | |
| 33 | } | |
| 23 | 34 | } |
| 24 | 35 | |
| 25 | private struct DeleteRepoResponse: Decodable, Sendable { | |
| 26 | let deleteRepository: DeletedRepo | |
| 36 | private struct DeleteRepositoryResponse: Decodable, Sendable { | |
| 37 | let deleteRepository: DeletedRepositoryPayload | |
| 27 | 38 | } |
| 28 | 39 | |
| 29 | private struct DeletedRepo: Decodable, Sendable { | |
| 40 | private struct DeletedRepositoryPayload: Decodable, Sendable { | |
| 30 | 41 | let id: Int |
| 31 | 42 | } |
| 32 | 43 | |
| 33 | // MARK: - View Model | |
| 34 | ||
| 35 | 44 | @Observable |
| 36 | 45 | @MainActor |
| 37 | 46 | final class RepositorySettingsViewModel { |
| 38 | ||
| 39 | 47 | let repositoryId: Int |
| 40 | 48 | let repositoryRid: String |
| 41 | 49 | let service: SRHTService |
| 42 | private let client: SRHTClient | |
| 43 | 50 | |
| 44 | // MARK: - Info fields | |
| 51 | private let client: SRHTClient | |
| 52 | private(set) var repository: RepositorySummary | |
| 45 | 53 | |
| 54 | var editedName: String | |
| 46 | 55 | var editedDescription: String |
| 47 | 56 | var editedVisibility: Visibility |
| 48 | 57 | var editedHead: String |
| 49 | private let originalEditedHead: String | |
| 50 | var isSavingInfo = false | |
| 51 | 58 | |
| 52 | // MARK: - Rename fields | |
| 59 | private(set) var branches: [ReferenceDetail] | |
| 60 | var isSavingMetadata = false | |
| 61 | var isSavingDefaultBranch = false | |
| 62 | var isUpdatingVisibility = false | |
| 63 | var isDeleting = false | |
| 64 | var error: String? | |
| 65 | var didDelete = false | |
| 53 | 66 | |
| 54 | var editedName: String | |
| 55 | var isRenaming = false | |
| 67 | var isMutating: Bool { | |
| 68 | isSavingMetadata || isSavingDefaultBranch || isUpdatingVisibility || isDeleting | |
| 69 | } | |
| 56 | 70 | |
| 57 | // MARK: - Delete state | |
| 71 | var currentDefaultBranchName: String { | |
| 72 | repository.defaultBranchName ?? "Not set" | |
| 73 | } | |
| 58 | 74 | |
| 59 | var isDeleting = false | |
| 75 | var availableBranchNames: [String] { | |
| 76 | branches.map { RepositorySummary.displayBranchName(for: $0.name) } | |
| 77 | } | |
| 60 | 78 | |
| 61 | // MARK: - Branches (for HEAD picker) | |
| 79 | var normalizedEditedName: String { | |
| 80 | editedName.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 81 | } | |
| 62 | 82 | |
| 63 | var branches: [ReferenceDetail] | |
| 83 | var normalizedEditedDescription: String { | |
| 84 | editedDescription.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 85 | } | |
| 64 | 86 | |
| 65 | // MARK: - Results | |
| 87 | var metadataValidationMessage: String? { | |
| 88 | Self.metadataValidationMessage(for: normalizedEditedName) | |
| 89 | } | |
| 66 | 90 | |
| 67 | var error: String? | |
| 68 | var updatedName: String? | |
| 69 | var didDelete = false | |
| 91 | var defaultBranchValidationMessage: String? { | |
| 92 | guard !branches.isEmpty else { | |
| 93 | return "This repository doesn't have any branches yet." | |
| 94 | } | |
| 95 | let normalizedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 96 | guard !normalizedHead.isEmpty else { | |
| 97 | return "Select a default branch." | |
| 98 | } | |
| 99 | guard availableBranchNames.contains(normalizedHead) else { | |
| 100 | return "Select one of the available branches." | |
| 101 | } | |
| 102 | return nil | |
| 103 | } | |
| 104 | ||
| 105 | var isMetadataDirty: Bool { | |
| 106 | normalizedEditedName != repository.name || | |
| 107 | normalizedEditedDescription != (repository.description ?? "") | |
| 108 | } | |
| 109 | ||
| 110 | var isDefaultBranchDirty: Bool { | |
| 111 | editedHead.trimmingCharacters(in: .whitespacesAndNewlines) != (repository.defaultBranchName ?? "") | |
| 112 | } | |
| 113 | ||
| 114 | var isVisibilityDirty: Bool { | |
| 115 | editedVisibility != repository.visibility | |
| 116 | } | |
| 70 | 117 | |
| 71 | 118 | init( |
| 72 | 119 | repository: RepositorySummary, |
| @@ -77,118 +124,103 @@ final class RepositorySettingsViewModel { | ||
| 77 | 124 | self.repositoryRid = repository.rid |
| 78 | 125 | self.service = repository.service |
| 79 | 126 | self.client = client |
| 127 | self.repository = repository | |
| 128 | self.branches = branches | |
| 129 | self.editedName = repository.name | |
| 80 | 130 | self.editedDescription = repository.description ?? "" |
| 81 | 131 | self.editedVisibility = repository.visibility |
| 82 | self.editedName = repository.name | |
| 83 | self.branches = branches | |
| 84 | ||
| 85 | // Extract branch name from HEAD reference | |
| 86 | let initialEditedHead: String | |
| 87 | if let head = repository.head?.name { | |
| 88 | initialEditedHead = head.replacingOccurrences(of: "refs/heads/", with: "") | |
| 89 | } else { | |
| 90 | initialEditedHead = "main" | |
| 91 | } | |
| 92 | self.editedHead = initialEditedHead | |
| 93 | self.originalEditedHead = initialEditedHead | |
| 132 | self.editedHead = repository.defaultBranchName ?? "" | |
| 94 | 133 | } |
| 95 | 134 | |
| 96 | // MARK: - Update Repository Info | |
| 97 | ||
| 98 | private static let updateRepoMutation = """ | |
| 135 | private static let updateRepositoryMutation = """ | |
| 99 | 136 | mutation updateRepository($id: Int!, $input: RepoInput!) { |
| 100 | 137 | updateRepository(id: $id, input: $input) { |
| 101 | id rid name description visibility | |
| 138 | id | |
| 139 | rid | |
| 140 | name | |
| 141 | description | |
| 142 | visibility | |
| 143 | updated | |
| 144 | HEAD { name target } | |
| 102 | 145 | } |
| 103 | 146 | } |
| 104 | 147 | """ |
| 105 | 148 | |
| 106 | private static let updateRepoInfoMutation = """ | |
| 107 | mutation updateRepository($id: Int!, $input: RepoInput!) { | |
| 108 | updateRepository(id: $id, input: $input) { | |
| 109 | id | |
| 110 | } | |
| 149 | private static let deleteRepositoryMutation = """ | |
| 150 | mutation deleteRepository($id: Int!) { | |
| 151 | deleteRepository(id: $id) { id } | |
| 111 | 152 | } |
| 112 | 153 | """ |
| 113 | 154 | |
| 114 | func saveInfo() async -> Bool { | |
| 115 | isSavingInfo = true | |
| 116 | defer { isSavingInfo = false } | |
| 155 | func saveMetadata() async -> RepositorySummary? { | |
| 156 | guard !isMutating else { return nil } | |
| 157 | if let metadataValidationMessage { | |
| 158 | error = metadataValidationMessage | |
| 159 | return nil | |
| 160 | } | |
| 161 | guard isMetadataDirty else { return repository } | |
| 162 | ||
| 163 | isSavingMetadata = true | |
| 164 | defer { isSavingMetadata = false } | |
| 117 | 165 | error = nil |
| 118 | 166 | |
| 167 | let input: [String: any Sendable] = [ | |
| 168 | "name": normalizedEditedName, | |
| 169 | "description": normalizedEditedDescription | |
| 170 | ] | |
| 171 | ||
| 119 | 172 | do { |
| 120 | var input: [String: any Sendable] = [ | |
| 121 | "description": editedDescription, | |
| 122 | "visibility": editedVisibility.rawValue | |
| 123 | ] | |
| 124 | if let headReference = selectedHeadReferenceForSave() { | |
| 125 | input["HEAD"] = headReference | |
| 126 | } | |
| 127 | _ = try await client.execute( | |
| 128 | service: service, | |
| 129 | query: Self.updateRepoInfoMutation, | |
| 130 | variables: ["id": repositoryId, "input": input], | |
| 131 | responseType: UpdateRepoInfoResponse.self | |
| 132 | ) | |
| 133 | return true | |
| 173 | return try await updateRepository(with: input) | |
| 134 | 174 | } catch { |
| 135 | self.error = error.userFacingMessage | |
| 136 | return false | |
| 175 | self.error = "Couldn't update repository details. \(error.userFacingMessage)" | |
| 176 | return nil | |
| 137 | 177 | } |
| 138 | 178 | } |
| 139 | 179 | |
| 140 | // MARK: - Rename | |
| 180 | func saveDefaultBranch() async -> RepositorySummary? { | |
| 181 | guard !isMutating else { return nil } | |
| 182 | if let defaultBranchValidationMessage { | |
| 183 | error = defaultBranchValidationMessage | |
| 184 | return nil | |
| 185 | } | |
| 186 | guard isDefaultBranchDirty else { return repository } | |
| 187 | guard let headReference = selectedHeadReferenceForSave() else { | |
| 188 | error = "Select one of the available branches." | |
| 189 | return nil | |
| 190 | } | |
| 141 | 191 | |
| 142 | func rename() async { | |
| 143 | isRenaming = true | |
| 144 | defer { isRenaming = false } | |
| 192 | isSavingDefaultBranch = true | |
| 193 | defer { isSavingDefaultBranch = false } | |
| 145 | 194 | error = nil |
| 146 | 195 | |
| 147 | 196 | do { |
| 148 | let input: [String: any Sendable] = [ | |
| 149 | "name": editedName | |
| 150 | ] | |
| 151 | let result = try await client.execute( | |
| 152 | service: service, | |
| 153 | query: Self.updateRepoMutation, | |
| 154 | variables: ["id": repositoryId, "input": input], | |
| 155 | responseType: UpdateRepoResponse.self | |
| 156 | ) | |
| 157 | updatedName = result.updateRepository.name | |
| 197 | return try await updateRepository(with: ["HEAD": headReference]) | |
| 158 | 198 | } catch { |
| 159 | self.error = error.userFacingMessage | |
| 199 | self.error = "Couldn't update the default branch. \(error.userFacingMessage)" | |
| 200 | return nil | |
| 160 | 201 | } |
| 161 | 202 | } |
| 162 | 203 | |
| 163 | static func gitHeadReference(from input: String) -> String { | |
| 164 | let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 165 | guard !trimmed.isEmpty else { return trimmed } | |
| 166 | if trimmed.hasPrefix("refs/") { | |
| 167 | return trimmed | |
| 168 | } | |
| 169 | return "refs/heads/\(trimmed)" | |
| 170 | } | |
| 204 | func updateVisibility() async -> RepositorySummary? { | |
| 205 | guard !isMutating else { return nil } | |
| 206 | guard isVisibilityDirty else { return repository } | |
| 171 | 207 | |
| 172 | func selectedHeadReferenceForSave() -> String? { | |
| 173 | let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 174 | guard normalizedEditedHead != originalEditedHead else { | |
| 208 | isUpdatingVisibility = true | |
| 209 | defer { isUpdatingVisibility = false } | |
| 210 | error = nil | |
| 211 | ||
| 212 | do { | |
| 213 | return try await updateRepository(with: ["visibility": editedVisibility.rawValue]) | |
| 214 | } catch { | |
| 215 | self.error = "Couldn't update visibility. \(error.userFacingMessage)" | |
| 216 | editedVisibility = repository.visibility | |
| 175 | 217 | return nil |
| 176 | 218 | } |
| 177 | ||
| 178 | return branches.first { | |
| 179 | $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead | |
| 180 | }?.name | |
| 181 | 219 | } |
| 182 | 220 | |
| 183 | // MARK: - Delete Repository | |
| 184 | ||
| 185 | private static let deleteRepoMutation = """ | |
| 186 | mutation deleteRepository($id: Int!) { | |
| 187 | deleteRepository(id: $id) { id } | |
| 188 | } | |
| 189 | """ | |
| 190 | ||
| 191 | 221 | func deleteRepository() async { |
| 222 | guard !isMutating else { return } | |
| 223 | ||
| 192 | 224 | isDeleting = true |
| 193 | 225 | defer { isDeleting = false } |
| 194 | 226 | error = nil |
| @@ -196,13 +228,69 @@ final class RepositorySettingsViewModel { | ||
| 196 | 228 | do { |
| 197 | 229 | _ = try await client.execute( |
| 198 | 230 | service: service, |
| 199 | query: Self.deleteRepoMutation, | |
| 231 | query: Self.deleteRepositoryMutation, | |
| 200 | 232 | variables: ["id": repositoryId], |
| 201 | responseType: DeleteRepoResponse.self | |
| 233 | responseType: DeleteRepositoryResponse.self | |
| 202 | 234 | ) |
| 203 | 235 | didDelete = true |
| 204 | 236 | } catch { |
| 205 | 237 | self.error = error.userFacingMessage |
| 206 | 238 | } |
| 207 | 239 | } |
| 240 | ||
| 241 | private func updateRepository(with input: [String: any Sendable]) async throws -> RepositorySummary { | |
| 242 | let result = try await client.execute( | |
| 243 | service: service, | |
| 244 | query: Self.updateRepositoryMutation, | |
| 245 | variables: ["id": repositoryId, "input": input], | |
| 246 | responseType: UpdateRepositoryResponse.self | |
| 247 | ) | |
| 248 | let updatedRepository = result.updateRepository.repositorySummary( | |
| 249 | using: repository.owner, | |
| 250 | service: service | |
| 251 | ) | |
| 252 | apply(updatedRepository) | |
| 253 | return updatedRepository | |
| 254 | } | |
| 255 | ||
| 256 | private func apply(_ updatedRepository: RepositorySummary) { | |
| 257 | repository = updatedRepository | |
| 258 | editedName = updatedRepository.name | |
| 259 | editedDescription = updatedRepository.description ?? "" | |
| 260 | editedVisibility = updatedRepository.visibility | |
| 261 | editedHead = updatedRepository.defaultBranchName ?? "" | |
| 262 | } | |
| 263 | ||
| 264 | static func metadataValidationMessage(for name: String) -> String? { | |
| 265 | guard !name.isEmpty else { | |
| 266 | return "Enter a repository name." | |
| 267 | } | |
| 268 | guard !name.contains("/") else { | |
| 269 | return "Repository names can't contain '/'." | |
| 270 | } | |
| 271 | guard name.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else { | |
| 272 | return "Repository names can't contain spaces." | |
| 273 | } | |
| 274 | return nil | |
| 275 | } | |
| 276 | ||
| 277 | static func gitHeadReference(from input: String) -> String { | |
| 278 | let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 279 | guard !trimmed.isEmpty else { return trimmed } | |
| 280 | if trimmed.hasPrefix("refs/") { | |
| 281 | return trimmed | |
| 282 | } | |
| 283 | return "refs/heads/\(trimmed)" | |
| 284 | } | |
| 285 | ||
| 286 | func selectedHeadReferenceForSave() -> String? { | |
| 287 | let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 288 | guard normalizedEditedHead != (repository.defaultBranchName ?? "") else { | |
| 289 | return nil | |
| 290 | } | |
| 291 | ||
| 292 | return branches.first { | |
| 293 | RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead | |
| 294 | }?.name | |
| 295 | } | |
| 208 | 296 | } |
HutchTests/RepositorySettingsViewModelTests.swift +26
| @@ -89,6 +89,32 @@ struct RepositorySettingsViewModelTests { | ||
| 89 | 89 | #expect(viewModel.selectedHeadReferenceForSave() == nil) |
| 90 | 90 | } |
| 91 | 91 | |
| 92 | @Test | |
| 93 | @MainActor | |
| 94 | func metadataValidationRejectsEmptyOrInvalidNames() { | |
| 95 | #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "") == "Enter a repository name.") | |
| 96 | #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "repo/name") == "Repository names can't contain '/'.") | |
| 97 | #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "repo name") == "Repository names can't contain spaces.") | |
| 98 | } | |
| 99 | ||
| 100 | @Test | |
| 101 | @MainActor | |
| 102 | func metadataDirtyStateTracksNormalizedInputs() { | |
| 103 | let viewModel = RepositorySettingsViewModel( | |
| 104 | repository: makeRepository(headName: "refs/heads/main"), | |
| 105 | branches: [ReferenceDetail(name: "refs/heads/main", target: nil, date: nil)], | |
| 106 | client: SRHTClient(token: "test-token") | |
| 107 | ) | |
| 108 | ||
| 109 | #expect(viewModel.isMetadataDirty == false) | |
| 110 | viewModel.editedDescription = " updated " | |
| 111 | #expect(viewModel.isMetadataDirty) | |
| 112 | ||
| 113 | viewModel.editedDescription = "desc" | |
| 114 | viewModel.editedName = " repo " | |
| 115 | #expect(viewModel.isMetadataDirty == false) | |
| 116 | } | |
| 117 | ||
| 92 | 118 | @MainActor |
| 93 | 119 | private func makeRepository(headName: String?) -> RepositorySummary { |
| 94 | 120 | RepositorySummary( |