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 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 44;
518 CURRENT_PROJECT_VERSION = 45;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 2.19.1;
535 MARKETING_VERSION = 2.19.2;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 44;
555 CURRENT_PROJECT_VERSION = 45;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 2.19.1;
572 MARKETING_VERSION = 2.19.2;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 44;
635 CURRENT_PROJECT_VERSION = 45;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 2.19.1;
645 MARKETING_VERSION = 2.19.2;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 44;
664 CURRENT_PROJECT_VERSION = 45;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 2.19.1;
674 MARKETING_VERSION = 2.19.2;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/Models/RepositorySummary.swift +34
@@ -55,3 +55,37 @@ struct RepositorySummary: Codable, Sendable, Identifiable, Hashable {
5555 self.head = try container.decodeIfPresent(Reference.self, forKey: .head)
5656 }
5757}
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 {
119119 } else {
120120 ForEach(viewModel.repositories.prefix(4)) { repo in
121121 NavigationLink {
122 RepositoryDetailView(repository: repo)
122 RepositoryDetailView(repository: repo) { updatedRepository in
123 viewModel.updateRepository(updatedRepository)
124 }
123125 } label: {
124126 RepositoryRowView(repository: repo, buildStatus: .none)
125127 }
Hutch/Views/Lookup/UserProfileViewModel.swift +11
@@ -61,6 +61,17 @@ final class UserProfileViewModel {
6161 }
6262 }
6363
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
6475 func loadTrackers() async {
6576 isLoadingTrackers = true
6677 trackersError = nil
Hutch/Views/Lookup/UserRepositoriesView.swift +3 −1
@@ -7,7 +7,9 @@ struct UserRepositoriesView: View {
77 List {
88 ForEach(viewModel.repositories) { repo in
99 NavigationLink {
10 RepositoryDetailView(repository: repo)
10 RepositoryDetailView(repository: repo) { updatedRepository in
11 viewModel.updateRepository(updatedRepository)
12 }
1113 } label: {
1214 RepositoryRowView(repository: repo, buildStatus: .none)
1315 }
Hutch/Views/Repositories/RepositoryDetailView.swift +21 −16
@@ -1,7 +1,7 @@
11import SwiftUI
22
33struct RepositoryDetailView: View {
4 var repository: RepositorySummary
4 let onRepositoryUpdated: ((RepositorySummary) -> Void)?
55 var onDeleted: (() -> Void)?
66
77 @Environment(AppState.self) private var appState
@@ -10,22 +10,26 @@ struct RepositoryDetailView: View {
1010 @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
1111 @State private var showSettings = false
1212 @State private var showACLs = false
13 @State private var displayName: String
13 @State private var currentRepository: RepositorySummary
1414
1515 private var canManageRepository: Bool {
1616 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)
1818 }
1919
20 init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) {
21 self.repository = repository
20 init(
21 repository: RepositorySummary,
22 onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil,
23 onDeleted: (() -> Void)? = nil
24 ) {
25 self.onRepositoryUpdated = onRepositoryUpdated
2226 self.onDeleted = onDeleted
23 self._displayName = State(initialValue: repository.name)
27 self._currentRepository = State(initialValue: repository)
2428 }
2529
2630 var body: some View {
27 if repository.service == .hg {
28 HgRepositoryDetailView(repository: repository, onDeleted: onDeleted)
31 if currentRepository.service == .hg {
32 HgRepositoryDetailView(repository: currentRepository, onDeleted: onDeleted)
2933 } else {
3034 Group {
3135 if let viewModel {
@@ -34,11 +38,11 @@ struct RepositoryDetailView: View {
3438 SRHTLoadingStateView(message: "Loading repository…")
3539 }
3640 }
37 .navigationTitle(displayName)
41 .navigationTitle(currentRepository.name)
3842 .navigationBarTitleDisplayMode(.inline)
3943 .toolbar {
4044 ToolbarItemGroup(placement: .topBarTrailing) {
41 SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) {
45 SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) {
4246 Image(systemName: "square.and.arrow.up")
4347 }
4448
@@ -59,11 +63,12 @@ struct RepositoryDetailView: View {
5963 }
6064 .sheet(isPresented: $showSettings) {
6165 RepositorySettingsView(
62 repository: repository,
66 repository: currentRepository,
6367 branches: viewModel?.branches ?? [],
6468 client: appState.client,
65 onRenamed: { newName in
66 displayName = newName
69 onUpdated: { updatedRepository in
70 currentRepository = updatedRepository
71 onRepositoryUpdated?(updatedRepository)
6772 },
6873 onDeleted: {
6974 dismiss()
@@ -74,7 +79,7 @@ struct RepositoryDetailView: View {
7479 .sheet(isPresented: $showACLs) {
7580 NavigationStack {
7681 RepositoryACLView(
77 repository: repository,
82 repository: currentRepository,
7883 client: appState.client,
7984 showsDoneButton: true
8085 )
@@ -83,7 +88,7 @@ struct RepositoryDetailView: View {
8388 .task {
8489 if viewModel == nil {
8590 viewModel = RepositoryDetailViewModel(
86 repository: repository,
91 repository: currentRepository,
8792 client: appState.client
8893 )
8994 }
@@ -110,7 +115,7 @@ struct RepositoryDetailView: View {
110115 ReadmeView(viewModel: viewModel)
111116 case .tree:
112117 FileTreeView(
113 repository: repository,
118 repository: currentRepository,
114119 client: appState.client
115120 )
116121 case .log:
Hutch/Views/Repositories/RepositoryListView.swift +13 −2
@@ -45,7 +45,13 @@ struct RepositoryListView: View {
4545 }
4646 )) {
4747 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 ) {
4955 viewModel?.removeRepository(id: createdRepository.id)
5056 }
5157 }
@@ -139,7 +145,12 @@ struct RepositoryListView: View {
139145 }
140146 }
141147 .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 ) {
143154 viewModel.removeRepository(id: repo.id)
144155 }
145156 }
Hutch/Views/Repositories/RepositoryListViewModel.swift +11
@@ -208,6 +208,17 @@ final class RepositoryListViewModel {
208208 repositories.removeAll { $0.id == id }
209209 }
210210
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
211222 func createRepository(
212223 service: RepositoryCreationService,
213224 name: String,
Hutch/Views/Repositories/RepositoryRowView.swift +2 −2
@@ -41,8 +41,8 @@ struct RepositoryRowView: View {
4141 }
4242
4343 HStack(spacing: 12) {
44 if let head = repository.head {
45 Label(head.name, systemImage: "arrow.triangle.branch")
44 if let defaultBranchName = repository.defaultBranchName {
45 Label(defaultBranchName, systemImage: "arrow.triangle.branch")
4646 .font(.caption)
4747 .foregroundStyle(.secondary)
4848 }
Hutch/Views/Repositories/RepositorySettingsView.swift +137 −89
@@ -4,14 +4,13 @@ struct RepositorySettingsView: View {
44 let repository: RepositorySummary
55 let branches: [ReferenceDetail]
66 let client: SRHTClient
7 let onRenamed: (String) -> Void
7 let onUpdated: (RepositorySummary) -> Void
88 let onDeleted: () -> Void
99
1010 @Environment(\.dismiss) private var dismiss
1111 @State private var viewModel: RepositorySettingsViewModel?
12 @State private var showVisibilityConfirmation = false
1213 @State private var showDeleteConfirmation = false
13 @State private var showRenameConfirmation = false
14 @State private var saveResultAlert: SaveResultAlert?
1514
1615 var body: some View {
1716 NavigationStack {
@@ -32,12 +31,11 @@ struct RepositorySettingsView: View {
3231 }
3332 .task {
3433 if viewModel == nil {
35 let vm = RepositorySettingsViewModel(
34 viewModel = RepositorySettingsViewModel(
3635 repository: repository,
3736 branches: branches,
3837 client: client
3938 )
40 viewModel = vm
4139 }
4240 }
4341 }
@@ -47,37 +45,36 @@ struct RepositorySettingsView: View {
4745 @Bindable var vm = viewModel
4846
4947 Form {
50 infoSection(viewModel)
51 renameSection(viewModel)
52 accessSection()
48 currentConfigurationSection(viewModel)
49 metadataSection(viewModel)
50 defaultBranchSection(viewModel)
51 visibilitySection(viewModel)
5352 deleteSection(viewModel)
5453 }
5554 .srhtErrorBanner(error: $vm.error)
5655 .alert(
57 "Rename repository to \(viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines))?",
58 isPresented: $showRenameConfirmation
56 visibilityConfirmationTitle(for: viewModel),
57 isPresented: $showVisibilityConfirmation
5958 ) {
6059 Button("Cancel", role: .cancel) {
61 // Alert dismissal is implicit; no additional action required.
60 viewModel.editedVisibility = viewModel.repository.visibility
6261 }
63 Button("Rename", role: .destructive) {
62 Button("Apply", role: .destructive) {
6463 Task {
65 await viewModel.rename()
66 if let newName = viewModel.updatedName {
67 onRenamed(newName)
68 dismiss()
64 if let updatedRepository = await viewModel.updateVisibility() {
65 onUpdated(updatedRepository)
6966 }
7067 }
7168 }
7269 } message: {
73 Text("This will change the repository URL. Existing clones will be redirected but links may break.")
70 Text(visibilityConfirmationMessage(for: viewModel))
7471 }
7572 .alert(
76 "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
73 "Permanently delete \(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)?",
7774 isPresented: $showDeleteConfirmation
7875 ) {
7976 Button("Cancel", role: .cancel) {
80 // Alert dismissal is implicit; no additional action required.
77 // Alert dismissal is implicit.
8178 }
8279 Button("Delete", role: .destructive) {
8380 Task {
@@ -91,115 +88,156 @@ struct RepositorySettingsView: View {
9188 } message: {
9289 Text("This cannot be undone.")
9390 }
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 }
10191 }
10292
103 // MARK: - Info Section
104
10593 @ViewBuilder
106 private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
107 Section("Info") {
108 LabeledContent("Name") {
109 Text(repository.name)
94 private func currentConfigurationSection(_ viewModel: RepositorySettingsViewModel) -> some View {
95 Section("Current Configuration") {
96 LabeledContent("Repository") {
97 Text("\(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)")
11098 .font(.body.monospaced())
11199 }
112100
113 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
114 .lineLimit(3...6)
101 LabeledContent("Default Branch") {
102 Text(viewModel.currentDefaultBranchName)
103 .font(.body.monospaced())
104 }
115105
116 Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
117 Text("Public").tag(Visibility.public)
118 Text("Unlisted").tag(Visibility.unlisted)
119 Text("Private").tag(Visibility.private)
106 LabeledContent("Visibility") {
107 Text(repositoryVisibilityLabel(viewModel.repository.visibility))
120108 }
109 }
110 }
121111
122 if !viewModel.branches.isEmpty {
123 Picker("Default Branch", selection: Bindable(viewModel).editedHead) {
124 ForEach(viewModel.branches, id: \.name) { branch in
125 let name = branch.name.replacingOccurrences(of: "refs/heads/", with: "")
126 Text(name).tag(name)
127 }
128 }
112 @ViewBuilder
113 private func metadataSection(_ viewModel: RepositorySettingsViewModel) -> some View {
114 Section {
115 TextField("Repository name", text: Bindable(viewModel).editedName)
116 .autocorrectionDisabled()
117 .textInputAutocapitalization(.never)
118
119 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
120 .lineLimit(2...4)
121
122 if let metadataValidationMessage = viewModel.metadataValidationMessage {
123 Text(metadataValidationMessage)
124 .font(.caption)
125 .foregroundStyle(.red)
126 } else if viewModel.normalizedEditedName != viewModel.repository.name {
127 Text("Changing the repository name updates the repository URL.")
128 .font(.caption)
129 .foregroundStyle(.secondary)
130 } else {
131 Text("Name and description stay pending until you save this section.")
132 .font(.caption)
133 .foregroundStyle(.secondary)
129134 }
130135
131136 Button {
132137 Task {
133 let didSave = await viewModel.saveInfo()
134 saveResultAlert = SaveResultAlert(
135 title: didSave ? "Settings Updated" : "Couldn't Update Settings",
136 message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
137 )
138 if let updatedRepository = await viewModel.saveMetadata() {
139 onUpdated(updatedRepository)
140 }
138141 }
139142 } label: {
140 if viewModel.isSavingInfo {
143 if viewModel.isSavingMetadata {
141144 ProgressView()
142145 .frame(maxWidth: .infinity)
143146 } else {
144 Text("Save Changes")
147 Text("Save Details")
145148 .frame(maxWidth: .infinity)
146149 }
147150 }
148 .disabled(viewModel.isSavingInfo)
151 .disabled(
152 viewModel.isMutating ||
153 !viewModel.isMetadataDirty ||
154 viewModel.metadataValidationMessage != nil
155 )
156 } header: {
157 Text("Repository Details")
149158 }
150159 }
151160
152 // MARK: - Rename Section
153
154161 @ViewBuilder
155 private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
162 private func defaultBranchSection(_ viewModel: RepositorySettingsViewModel) -> some View {
156163 Section {
157 TextField("New repository name", text: Bindable(viewModel).editedName)
158 .autocorrectionDisabled()
159 .textInputAutocapitalization(.never)
164 LabeledContent("Current") {
165 Text(viewModel.currentDefaultBranchName)
166 .font(.body.monospaced())
167 }
160168
161 Text("This will change the repository URL. Existing clones will be redirected but links may break.")
162 .font(.caption)
163 .foregroundStyle(.secondary)
169 if viewModel.branches.isEmpty {
170 Text("This repository doesn't have any branches yet.")
171 .foregroundStyle(.secondary)
172 } else {
173 Picker("Branch", selection: Bindable(viewModel).editedHead) {
174 ForEach(viewModel.availableBranchNames, id: \.self) { branch in
175 Text(branch)
176 .font(.body.monospaced())
177 .tag(branch)
178 }
179 }
164180
165 Button {
166 showRenameConfirmation = true
167 } label: {
168 if viewModel.isRenaming {
169 ProgressView()
170 .frame(maxWidth: .infinity)
171 } else {
172 Text("Rename Repository")
173 .frame(maxWidth: .infinity)
181 Text("Changes stay pending until you set the new default branch.")
182 .font(.caption)
183 .foregroundStyle(.secondary)
184
185 Button {
186 Task {
187 if let updatedRepository = await viewModel.saveDefaultBranch() {
188 onUpdated(updatedRepository)
189 }
190 }
191 } label: {
192 if viewModel.isSavingDefaultBranch {
193 ProgressView()
194 .frame(maxWidth: .infinity)
195 } else {
196 Text("Set Default Branch")
197 .frame(maxWidth: .infinity)
198 }
174199 }
200 .disabled(
201 viewModel.isMutating ||
202 !viewModel.isDefaultBranchDirty ||
203 viewModel.defaultBranchValidationMessage != nil
204 )
175205 }
176 .disabled(viewModel.isRenaming || viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
177206 } header: {
178 Text("Rename")
207 Text("Default Branch")
179208 }
180209 }
181210
182 // MARK: - Access Section
183
184211 @ViewBuilder
185 private func accessSection() -> some View {
212 private func visibilitySection(_ viewModel: RepositorySettingsViewModel) -> some View {
186213 Section {
187 NavigationLink {
188 RepositoryACLView(repository: repository, client: client, showsDoneButton: false)
189 } label: {
190 Label("Manage Access", systemImage: "person.2")
214 Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
215 Text("Public").tag(Visibility.public)
216 Text("Unlisted").tag(Visibility.unlisted)
217 Text("Private").tag(Visibility.private)
191218 }
192219
193 Text("Review and update repository access without leaving settings.")
220 Text("Visibility changes apply immediately after you confirm them.")
194221 .font(.caption)
195222 .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)
196236 } header: {
197 Text("Access")
237 Text("Sensitive Settings")
198238 }
199239 }
200240
201 // MARK: - Delete Section
202
203241 @ViewBuilder
204242 private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> some View {
205243 Section {
@@ -214,14 +252,24 @@ struct RepositorySettingsView: View {
214252 .frame(maxWidth: .infinity)
215253 }
216254 }
217 .disabled(viewModel.isDeleting)
255 .disabled(viewModel.isMutating)
256 } header: {
257 Text("Danger Zone")
218258 }
219259 }
220260
221 private struct SaveResultAlert: Identifiable {
222 let title: String
223 let message: String
261 private func visibilityConfirmationTitle(for viewModel: RepositorySettingsViewModel) -> String {
262 "Change visibility to \(repositoryVisibilityLabel(viewModel.editedVisibility))?"
263 }
224264
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 }
226274 }
227275}
Hutch/Views/Repositories/RepositorySettingsViewModel.swift +204 −116
@@ -1,72 +1,119 @@
11import Foundation
22
3// MARK: - Response types
4
5private struct UpdateRepoResponse: Decodable, Sendable {
6 let updateRepository: UpdatedRepo
7}
8
9private struct UpdateRepoInfoResponse: Decodable, Sendable {
10 let updateRepository: UpdatedRepoInfo
3private struct UpdateRepositoryResponse: Decodable, Sendable {
4 let updateRepository: UpdatedRepositoryPayload
115}
126
13private struct UpdatedRepo: Decodable, Sendable {
14 let id: Int?
15 let rid: String?
7private struct UpdatedRepositoryPayload: Decodable, Sendable {
8 let id: Int
9 let rid: String
1610 let name: String
1711 let description: String?
18 let visibility: Visibility?
19}
12 let visibility: Visibility
13 let updated: Date
14 let head: Reference?
2015
21private struct UpdatedRepoInfo: Decodable, Sendable {
22 let id: Int
16 enum CodingKeys: String, CodingKey {
17 case id, rid, name, description, visibility, updated
18 case head = "HEAD"
19 }
20
21 func repositorySummary(using owner: Entity, service: SRHTService) -> RepositorySummary {
22 RepositorySummary(
23 id: id,
24 rid: rid,
25 service: service,
26 name: name,
27 description: description,
28 visibility: visibility,
29 updated: updated,
30 owner: owner,
31 head: head
32 )
33 }
2334}
2435
25private struct DeleteRepoResponse: Decodable, Sendable {
26 let deleteRepository: DeletedRepo
36private struct DeleteRepositoryResponse: Decodable, Sendable {
37 let deleteRepository: DeletedRepositoryPayload
2738}
2839
29private struct DeletedRepo: Decodable, Sendable {
40private struct DeletedRepositoryPayload: Decodable, Sendable {
3041 let id: Int
3142}
3243
33// MARK: - View Model
34
3544@Observable
3645@MainActor
3746final class RepositorySettingsViewModel {
38
3947 let repositoryId: Int
4048 let repositoryRid: String
4149 let service: SRHTService
42 private let client: SRHTClient
4350
44 // MARK: - Info fields
51 private let client: SRHTClient
52 private(set) var repository: RepositorySummary
4553
54 var editedName: String
4655 var editedDescription: String
4756 var editedVisibility: Visibility
4857 var editedHead: String
49 private let originalEditedHead: String
50 var isSavingInfo = false
5158
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
5366
54 var editedName: String
55 var isRenaming = false
67 var isMutating: Bool {
68 isSavingMetadata || isSavingDefaultBranch || isUpdatingVisibility || isDeleting
69 }
5670
57 // MARK: - Delete state
71 var currentDefaultBranchName: String {
72 repository.defaultBranchName ?? "Not set"
73 }
5874
59 var isDeleting = false
75 var availableBranchNames: [String] {
76 branches.map { RepositorySummary.displayBranchName(for: $0.name) }
77 }
6078
61 // MARK: - Branches (for HEAD picker)
79 var normalizedEditedName: String {
80 editedName.trimmingCharacters(in: .whitespacesAndNewlines)
81 }
6282
63 var branches: [ReferenceDetail]
83 var normalizedEditedDescription: String {
84 editedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
85 }
6486
65 // MARK: - Results
87 var metadataValidationMessage: String? {
88 Self.metadataValidationMessage(for: normalizedEditedName)
89 }
6690
67 var error: String?
68 var updatedName: String?
69 var didDelete = false
91 var defaultBranchValidationMessage: String? {
92 guard !branches.isEmpty else {
93 return "This repository doesn't have any branches yet."
94 }
95 let normalizedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
96 guard !normalizedHead.isEmpty else {
97 return "Select a default branch."
98 }
99 guard availableBranchNames.contains(normalizedHead) else {
100 return "Select one of the available branches."
101 }
102 return nil
103 }
104
105 var isMetadataDirty: Bool {
106 normalizedEditedName != repository.name ||
107 normalizedEditedDescription != (repository.description ?? "")
108 }
109
110 var isDefaultBranchDirty: Bool {
111 editedHead.trimmingCharacters(in: .whitespacesAndNewlines) != (repository.defaultBranchName ?? "")
112 }
113
114 var isVisibilityDirty: Bool {
115 editedVisibility != repository.visibility
116 }
70117
71118 init(
72119 repository: RepositorySummary,
@@ -77,118 +124,103 @@ final class RepositorySettingsViewModel {
77124 self.repositoryRid = repository.rid
78125 self.service = repository.service
79126 self.client = client
127 self.repository = repository
128 self.branches = branches
129 self.editedName = repository.name
80130 self.editedDescription = repository.description ?? ""
81131 self.editedVisibility = repository.visibility
82 self.editedName = repository.name
83 self.branches = branches
84
85 // Extract branch name from HEAD reference
86 let initialEditedHead: String
87 if let head = repository.head?.name {
88 initialEditedHead = head.replacingOccurrences(of: "refs/heads/", with: "")
89 } else {
90 initialEditedHead = "main"
91 }
92 self.editedHead = initialEditedHead
93 self.originalEditedHead = initialEditedHead
132 self.editedHead = repository.defaultBranchName ?? ""
94133 }
95134
96 // MARK: - Update Repository Info
97
98 private static let updateRepoMutation = """
135 private static let updateRepositoryMutation = """
99136 mutation updateRepository($id: Int!, $input: RepoInput!) {
100137 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 }
102145 }
103146 }
104147 """
105148
106 private static let updateRepoInfoMutation = """
107 mutation updateRepository($id: Int!, $input: RepoInput!) {
108 updateRepository(id: $id, input: $input) {
109 id
110 }
149 private static let deleteRepositoryMutation = """
150 mutation deleteRepository($id: Int!) {
151 deleteRepository(id: $id) { id }
111152 }
112153 """
113154
114 func saveInfo() async -> Bool {
115 isSavingInfo = true
116 defer { isSavingInfo = false }
155 func saveMetadata() async -> RepositorySummary? {
156 guard !isMutating else { return nil }
157 if let metadataValidationMessage {
158 error = metadataValidationMessage
159 return nil
160 }
161 guard isMetadataDirty else { return repository }
162
163 isSavingMetadata = true
164 defer { isSavingMetadata = false }
117165 error = nil
118166
167 let input: [String: any Sendable] = [
168 "name": normalizedEditedName,
169 "description": normalizedEditedDescription
170 ]
171
119172 do {
120 var input: [String: any Sendable] = [
121 "description": editedDescription,
122 "visibility": editedVisibility.rawValue
123 ]
124 if let headReference = selectedHeadReferenceForSave() {
125 input["HEAD"] = headReference
126 }
127 _ = try await client.execute(
128 service: service,
129 query: Self.updateRepoInfoMutation,
130 variables: ["id": repositoryId, "input": input],
131 responseType: UpdateRepoInfoResponse.self
132 )
133 return true
173 return try await updateRepository(with: input)
134174 } catch {
135 self.error = error.userFacingMessage
136 return false
175 self.error = "Couldn't update repository details. \(error.userFacingMessage)"
176 return nil
137177 }
138178 }
139179
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 }
141191
142 func rename() async {
143 isRenaming = true
144 defer { isRenaming = false }
192 isSavingDefaultBranch = true
193 defer { isSavingDefaultBranch = false }
145194 error = nil
146195
147196 do {
148 let input: [String: any Sendable] = [
149 "name": editedName
150 ]
151 let result = try await client.execute(
152 service: service,
153 query: Self.updateRepoMutation,
154 variables: ["id": repositoryId, "input": input],
155 responseType: UpdateRepoResponse.self
156 )
157 updatedName = result.updateRepository.name
197 return try await updateRepository(with: ["HEAD": headReference])
158198 } catch {
159 self.error = error.userFacingMessage
199 self.error = "Couldn't update the default branch. \(error.userFacingMessage)"
200 return nil
160201 }
161202 }
162203
163 static func gitHeadReference(from input: String) -> String {
164 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
165 guard !trimmed.isEmpty else { return trimmed }
166 if trimmed.hasPrefix("refs/") {
167 return trimmed
168 }
169 return "refs/heads/\(trimmed)"
170 }
204 func updateVisibility() async -> RepositorySummary? {
205 guard !isMutating else { return nil }
206 guard isVisibilityDirty else { return repository }
171207
172 func selectedHeadReferenceForSave() -> String? {
173 let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
174 guard normalizedEditedHead != originalEditedHead else {
208 isUpdatingVisibility = true
209 defer { isUpdatingVisibility = false }
210 error = nil
211
212 do {
213 return try await updateRepository(with: ["visibility": editedVisibility.rawValue])
214 } catch {
215 self.error = "Couldn't update visibility. \(error.userFacingMessage)"
216 editedVisibility = repository.visibility
175217 return nil
176218 }
177
178 return branches.first {
179 $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead
180 }?.name
181219 }
182220
183 // MARK: - Delete Repository
184
185 private static let deleteRepoMutation = """
186 mutation deleteRepository($id: Int!) {
187 deleteRepository(id: $id) { id }
188 }
189 """
190
191221 func deleteRepository() async {
222 guard !isMutating else { return }
223
192224 isDeleting = true
193225 defer { isDeleting = false }
194226 error = nil
@@ -196,13 +228,69 @@ final class RepositorySettingsViewModel {
196228 do {
197229 _ = try await client.execute(
198230 service: service,
199 query: Self.deleteRepoMutation,
231 query: Self.deleteRepositoryMutation,
200232 variables: ["id": repositoryId],
201 responseType: DeleteRepoResponse.self
233 responseType: DeleteRepositoryResponse.self
202234 )
203235 didDelete = true
204236 } catch {
205237 self.error = error.userFacingMessage
206238 }
207239 }
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 }
208296}
HutchTests/RepositorySettingsViewModelTests.swift +26
@@ -89,6 +89,32 @@ struct RepositorySettingsViewModelTests {
8989 #expect(viewModel.selectedHeadReferenceForSave() == nil)
9090 }
9191
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
92118 @MainActor
93119 private func makeRepository(headName: String?) -> RepositorySummary {
94120 RepositorySummary(