Commit e8d7a07087

e8d7a0708778c3ba8c6ce36454818d1a192dfae3

parent: 3b74c3360e

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 03:30 UTC

feat: add repo settings management

Implements: https://todo.sr.ht/~ccleberg/hutch/42
Implements: https://todo.sr.ht/~ccleberg/hutch/43
Implements: https://todo.sr.ht/~ccleberg/hutch/44

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
59extension 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 @@
1import SwiftUI 1import SwiftUI
2 2
3struct RepositoryDetailView: View { 3struct 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 @@
1import Foundation 1import Foundation
2 2
3// MARK: - Response types 3private struct UpdateRepositoryResponse: Decodable, Sendable {
4 4 let updateRepository: UpdatedRepositoryPayload
5private struct UpdateRepoResponse: Decodable, Sendable {
6 let updateRepository: UpdatedRepo
7}
8
9private struct UpdateRepoInfoResponse: Decodable, Sendable {
10 let updateRepository: UpdatedRepoInfo
11} 5}
12 6
13private struct UpdatedRepo: Decodable, Sendable { 7private 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
21private 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
25private struct DeleteRepoResponse: Decodable, Sendable { 36private struct DeleteRepositoryResponse: Decodable, Sendable {
26 let deleteRepository: DeletedRepo 37 let deleteRepository: DeletedRepositoryPayload
27} 38}
28 39
29private struct DeletedRepo: Decodable, Sendable { 40private 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
37final class RepositorySettingsViewModel { 46final 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(