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