Commit b2f3bce0c2

b2f3bce0c254e11c4a7e871c3bb844a09c53a96e

parent: 14fd4b36ec

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 19:20 UTC

fix: mercurial parity and graphql handling

- implements consistent UX between repo types
- ensures centralized services and routes are used between repo types
- graphql error handling is centralized
- error messages are consistent

Implements: https://todo.sr.ht/~ccleberg/hutch/58
Implements: https://todo.sr.ht/~ccleberg/hutch/59

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 = 62;
518 CURRENT_PROJECT_VERSION = 65;
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 = 3.0.1;
535 MARKETING_VERSION = 3.0.4;
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 = 62;
555 CURRENT_PROJECT_VERSION = 65;
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 = 3.0.1;
572 MARKETING_VERSION = 3.0.4;
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 = 62;
635 CURRENT_PROJECT_VERSION = 65;
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 = 3.0.1;
645 MARKETING_VERSION = 3.0.4;
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 = 62;
664 CURRENT_PROJECT_VERSION = 65;
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 = 3.0.1;
674 MARKETING_VERSION = 3.0.4;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/Networking/SRHTClient.swift +16 −17
@@ -89,19 +89,12 @@ final class SRHTClient: Sendable {
8989 throw SRHTError.unauthorized
9090 }
9191 if !(200...299).contains(http.statusCode) {
92 // Try to extract GraphQL errors from the response body even on non-2xx
93 if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
94 let errors = gqlResponse.errors, !errors.isEmpty {
95 throw SRHTError.graphQLErrors(errors)
96 }
92 try throwGraphQLErrorsIfPresent(in: data)
9793 throw SRHTError.httpError(http.statusCode)
9894 }
9995 }
10096
101 if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
102 let errors = errorEnvelope.errors, !errors.isEmpty {
103 throw SRHTError.graphQLErrors(errors)
104 }
97 try throwGraphQLErrorsIfPresent(in: data)
10598
10699 // Decode GraphQL response envelope
107100 let graphQLResponse: GraphQLResponse<T>
@@ -249,14 +242,12 @@ final class SRHTClient: Sendable {
249242 throw SRHTError.unauthorized
250243 }
251244 if !(200...299).contains(http.statusCode) {
245 try throwGraphQLErrorsIfPresent(in: data)
252246 throw SRHTError.httpError(http.statusCode)
253247 }
254248 }
255249
256 if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
257 let errors = errorEnvelope.errors, !errors.isEmpty {
258 throw SRHTError.graphQLErrors(errors)
259 }
250 try throwGraphQLErrorsIfPresent(in: data)
260251
261252 let graphQLResponse: GraphQLResponse<T>
262253 do {
@@ -381,14 +372,13 @@ final class SRHTClient: Sendable {
381372 throw SRHTError.unauthorized
382373 }
383374 if !(200...299).contains(http.statusCode) {
384 if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
385 let errors = gqlResponse.errors, !errors.isEmpty {
386 throw SRHTError.graphQLErrors(errors)
387 }
375 try throwGraphQLErrorsIfPresent(in: data)
388376 throw SRHTError.httpError(http.statusCode)
389377 }
390378 }
391379
380 try throwGraphQLErrorsIfPresent(in: data)
381
392382 let graphQLResponse: GraphQLResponse<T>
393383 do {
394384 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
@@ -474,6 +464,7 @@ final class SRHTClient: Sendable {
474464 throw SRHTError.unauthorized
475465 }
476466 if !(200...299).contains(http.statusCode) {
467 try throwGraphQLErrorsIfPresent(in: data)
477468 throw SRHTError.httpError(http.statusCode)
478469 }
479470 }
@@ -627,6 +618,14 @@ final class SRHTClient: Sendable {
627618// MARK: - Data Helper
628619
629620private extension SRHTClient {
621 func throwGraphQLErrorsIfPresent(in data: Data) throws {
622 if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
623 let errors = envelope.errors,
624 !errors.isEmpty {
625 throw SRHTError.graphQLErrors(errors)
626 }
627 }
628
630629 static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool {
631630 guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame,
632631 let host = url.host?.lowercased() else {
Hutch/Networking/SRHTError.swift +80 −8
@@ -18,7 +18,7 @@ enum SRHTError: LocalizedError, Sendable {
1818 var errorDescription: String? {
1919 switch self {
2020 case .graphQLErrors(let errors):
21 let messages = errors.map(\.message).joined(separator: "\n")
21 let messages = errors.diagnosticSummary
2222 return "GraphQL error: \(messages)"
2323 case .httpError(let code):
2424 return "Server returned HTTP \(code)."
@@ -33,17 +33,21 @@ enum SRHTError: LocalizedError, Sendable {
3333 }
3434 }
3535
36 var userFacingMessage: String {
36 nonisolated var userFacingMessage: String {
3737 switch self {
3838 case .graphQLErrors(let errors):
39 let firstMessage = errors.first?.message.lowercased() ?? ""
40 if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") {
39 switch errors.classification {
40 case .unauthorized, .forbidden:
4141 return "You do not have permission to do that."
42 }
43 if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") {
42 case .notFound, .noRows, .missingReference, .unknownRevision:
4443 return "That content is no longer available."
44 case .serviceNotProvisioned:
45 return "That account needs to activate this SourceHut service before this action can succeed."
46 case .validation:
47 return errors.primaryMessage ?? "Please review your changes and try again."
48 case .other:
49 return "Something went wrong. Please try again."
4550 }
46 return "Something went wrong. Please try again."
4751 case .httpError(let code):
4852 if code == 401 {
4953 return "Please sign in again."
@@ -105,7 +109,7 @@ enum SRHTError: LocalizedError, Sendable {
105109}
106110
107111extension Error {
108 var userFacingMessage: String {
112 nonisolated var userFacingMessage: String {
109113 if let error = self as? SRHTError {
110114 return error.userFacingMessage
111115 }
@@ -125,6 +129,22 @@ extension Error {
125129 return "Something went wrong. Please try again."
126130 }
127131 }
132
133 nonisolated var graphQLErrors: [GraphQLError]? {
134 guard let srhtError = self as? SRHTError,
135 case let SRHTError.graphQLErrors(errors) = srhtError else {
136 return nil
137 }
138 return errors
139 }
140
141 nonisolated func matchesGraphQLErrorClassification(_ classification: GraphQLErrorClassification) -> Bool {
142 graphQLErrors?.classification == classification
143 }
144
145 nonisolated func containsGraphQLErrorMessage(_ fragment: String) -> Bool {
146 graphQLErrors?.containsMessage(fragment) == true
147 }
128148}
129149
130150/// A single error entry from the GraphQL `errors` array.
@@ -133,6 +153,58 @@ struct GraphQLError: Decodable, Sendable {
133153 let locations: [GraphQLErrorLocation]?
134154}
135155
156enum GraphQLErrorClassification: Sendable {
157 case unauthorized
158 case forbidden
159 case notFound
160 case noRows
161 case missingReference
162 case unknownRevision
163 case serviceNotProvisioned
164 case validation
165 case other
166}
167
168extension Array where Element == GraphQLError {
169 nonisolated var classification: GraphQLErrorClassification {
170 if containsMessage("unauthorized") { return .unauthorized }
171 if containsMessage("forbidden") { return .forbidden }
172 if containsMessage("reference not found") { return .missingReference }
173 if containsMessage("no rows in result set") { return .noRows }
174 if containsMessage("unknown revision") || containsMessage("path not in the working tree") {
175 return .unknownRevision
176 }
177 if containsMessage("not found") || containsMessage("no such") || containsMessage("missing revision") {
178 return .notFound
179 }
180 if containsMessage("no such repository or user found") {
181 return .serviceNotProvisioned
182 }
183 if let primaryMessage, !primaryMessage.isEmpty {
184 return .validation
185 }
186 return .other
187 }
188
189 nonisolated var primaryMessage: String? {
190 let candidates = map(\.message)
191 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
192 .filter { !$0.isEmpty }
193 return candidates.first
194 }
195
196 nonisolated var diagnosticSummary: String {
197 map(\.message)
198 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
199 .filter { !$0.isEmpty }
200 .joined(separator: "\n")
201 }
202
203 nonisolated func containsMessage(_ fragment: String) -> Bool {
204 contains { $0.message.localizedCaseInsensitiveContains(fragment) }
205 }
206}
207
136208struct GraphQLErrorLocation: Decodable, Sendable {
137209 let line: Int
138210 let column: Int
Hutch/Networking/SystemStatusRepository.swift +1 −50
@@ -130,56 +130,7 @@ actor SystemStatusRepository {
130130 }
131131
132132 private func refreshErrorMessage(from error: any Error) -> String {
133 if let error = error as? SRHTError {
134 switch error {
135 case .graphQLErrors(let errors):
136 let firstMessage = errors.first?.message.lowercased() ?? ""
137 if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") {
138 return "You do not have permission to do that."
139 }
140 if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") {
141 return "That content is no longer available."
142 }
143 return "Something went wrong. Please try again."
144 case .httpError(let code):
145 if code == 401 {
146 return "Please sign in again."
147 }
148 if code == 403 {
149 return "You do not have permission to do that."
150 }
151 if code == 404 {
152 return "That content is no longer available."
153 }
154 if (500...599).contains(code) {
155 return "The server is unavailable right now. Please try again."
156 }
157 return "Something went wrong. Please try again."
158 case .invalidAuthenticatedURL:
159 return "That request could not be completed."
160 case .decodingError:
161 return "The response could not be loaded right now."
162 case .networkError(let underlyingError):
163 return refreshErrorMessage(from: underlyingError)
164 case .unauthorized:
165 return "Please sign in again."
166 }
167 }
168
169 let nsError = error as NSError
170 switch nsError.code {
171 case NSURLErrorNotConnectedToInternet,
172 NSURLErrorNetworkConnectionLost,
173 NSURLErrorTimedOut,
174 NSURLErrorCannotFindHost,
175 NSURLErrorCannotConnectToHost,
176 NSURLErrorDNSLookupFailed,
177 NSURLErrorInternationalRoamingOff,
178 NSURLErrorDataNotAllowed:
179 return "Check your connection and try again."
180 default:
181 return "Something went wrong. Please try again."
182 }
133 error.userFacingMessage
183134 }
184135}
185136
Hutch/Views/Builds/BuildListView.swift +46 −41
@@ -130,66 +130,71 @@ struct BuildListView: View {
130130 }
131131 }
132132 .pickerStyle(.segmented)
133 .listRowBackground(Color.clear)
133 .padding(.horizontal, 16)
134 .padding(.top, 6)
135 .padding(.bottom, 10)
134136 .listRowInsets(EdgeInsets())
135 }
137 .listRowBackground(Color.clear)
138 .listRowSeparator(.hidden)
136139
137 ForEach(viewModel.filteredJobs) { job in
138 NavigationLink(value: job) {
139 BuildRowView(job: job)
140 .equatable()
141 }
142 .contextMenu {
143 Button {
144 appState.copyToPasteboard(String(job.id), label: "job ID")
145 } label: {
146 Label("Copy Job ID", systemImage: "doc.on.doc")
140 ForEach(viewModel.filteredJobs) { job in
141 NavigationLink(value: job) {
142 BuildRowView(job: job)
143 .equatable()
147144 }
148
149 if let note = job.note, !note.isEmpty {
145 .contextMenu {
150146 Button {
151 appState.copyToPasteboard(note, label: "build note")
147 appState.copyToPasteboard(String(job.id), label: "job ID")
152148 } label: {
153 Label("Copy Note", systemImage: "text.alignleft")
149 Label("Copy Job ID", systemImage: "doc.on.doc")
154150 }
155 }
156151
157 if !job.tags.isEmpty {
158 Button {
159 appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags")
160 } label: {
161 Label("Copy Tags", systemImage: "tag")
152 if let note = job.note, !note.isEmpty {
153 Button {
154 appState.copyToPasteboard(note, label: "build note")
155 } label: {
156 Label("Copy Note", systemImage: "text.alignleft")
157 }
158 }
159
160 if !job.tags.isEmpty {
161 Button {
162 appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags")
163 } label: {
164 Label("Copy Tags", systemImage: "tag")
165 }
162166 }
163167 }
164 }
165 .swipeActions(edge: .leading, allowsFullSwipe: true) {
166 if swipeActionsEnabled, job.status.isCancellable {
167 Button {
168 Task {
169 await viewModel.cancelJob(job)
168 .swipeActions(edge: .leading, allowsFullSwipe: true) {
169 if swipeActionsEnabled, job.status.isCancellable {
170 Button {
171 Task {
172 await viewModel.cancelJob(job)
173 }
174 } label: {
175 Label("Cancel", systemImage: "xmark.circle")
170176 }
171 } label: {
172 Label("Cancel", systemImage: "xmark.circle")
177 .tint(.red)
173178 }
174 .tint(.red)
179 }
180 .task {
181 await viewModel.loadMoreIfNeeded(currentItem: job)
175182 }
176183 }
177 .task {
178 await viewModel.loadMoreIfNeeded(currentItem: job)
179 }
180 }
181184
182 if viewModel.isLoadingMore {
183 HStack {
184 Spacer()
185 ProgressView()
186 Spacer()
185 if viewModel.isLoadingMore {
186 HStack {
187 Spacer()
188 ProgressView()
189 Spacer()
190 }
191 .listRowSeparator(.hidden)
187192 }
188 .listRowSeparator(.hidden)
189193 }
190194 }
191195 .themedList()
192196 .listStyle(.plain)
197 .listSectionSpacing(.compact)
193198 .searchable(
194199 text: $vm.searchText,
195200 placement: .navigationBarDrawer(displayMode: .always),
Hutch/Views/Inbox/ThreadViewModel.swift +1 −4
@@ -693,9 +693,6 @@ final class ThreadViewModel {
693693 }
694694
695695 private static func isRecoverableNoRows(_ error: Error) -> Bool {
696 guard case let SRHTError.graphQLErrors(errors) = error else {
697 return false
698 }
699 return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") }
696 error.matchesGraphQLErrorClassification(.noRows)
700697 }
701698}
Hutch/Views/Repositories/HgRepositoryDetailView.swift +45 −4
@@ -7,6 +7,7 @@ struct HgRepositoryDetailView: View {
77
88 @Environment(AppState.self) private var appState
99 @Environment(\.dismiss) private var dismiss
10 @Environment(\.openURL) private var openURL
1011 @Environment(\.colorScheme) private var colorScheme
1112
1213 @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false
@@ -99,6 +100,7 @@ struct HgRepositoryDetailView: View {
99100 async let log: () = vm.loadLog()
100101 _ = await (summary, browse, log)
101102 }
103 RecentActivityStore.recordRepository(repository, defaults: appState.accountDefaults)
102104 }
103105 }
104106
@@ -132,6 +134,42 @@ struct HgRepositoryDetailView: View {
132134 }
133135 }
134136
137 Divider()
138
139 if let repositoryURL = SRHTWebURL.repository(repository) {
140 Button {
141 openURL(repositoryURL)
142 } label: {
143 Label("Open in Browser", systemImage: "safari")
144 }
145
146 Button {
147 appState.copyToPasteboard(repositoryURL.absoluteString, label: "repository URL")
148 } label: {
149 Label("Copy URL", systemImage: "doc.on.doc")
150 }
151 }
152
153 if let httpsURL = SRHTWebURL.httpsCloneURL(repository) {
154 Button {
155 appState.copyToPasteboard(httpsURL, label: "HTTPS clone URL")
156 } label: {
157 Label("Copy HTTPS URL", systemImage: "doc.on.doc")
158 }
159 }
160
161 Button {
162 appState.copyToPasteboard(SRHTWebURL.sshCloneURL(repository), label: "SSH clone URL")
163 } label: {
164 Label("Copy SSH URL", systemImage: "terminal")
165 }
166
167 Button {
168 appState.copyToPasteboard(repository.rid, label: "repository RID")
169 } label: {
170 Label("Copy RID", systemImage: "number")
171 }
172
135173 if canManageRepository {
136174 Divider()
137175
@@ -227,10 +265,12 @@ struct HgRepositoryDetailView: View {
227265 @ViewBuilder
228266 private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
229267 VStack(alignment: .leading, spacing: 10) {
230 SummaryMetadataRow(
231 icon: "arrow.triangle.branch",
232 title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility)
233 )
268 if let branchLabel = repositoryPrimaryBranchLabel(for: repository, hgTipBranch: viewModel.tip?.branch) {
269 SummaryMetadataRow(
270 icon: "arrow.triangle.branch",
271 title: branchLabel
272 )
273 }
234274
235275 if let readmePath = viewModel.readmePath {
236276 SummaryMetadataRow(
@@ -244,6 +284,7 @@ struct HgRepositoryDetailView: View {
244284 private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
245285 DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
246286 VStack(alignment: .leading, spacing: 12) {
287 SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(repository.service))
247288 SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility))
248289 SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
249290 SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true)
Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift +4 −15
@@ -526,20 +526,9 @@ final class HgRepositoryDetailViewModel {
526526 }
527527
528528 private func isEmptyRepositoryError(_ error: Error) -> Bool {
529 if let srhtError = error as? SRHTError,
530 case .graphQLErrors(let errors) = srhtError {
531 return errors.contains {
532 let message = $0.message.localizedLowercase
533 return message.contains("missing")
534 || message.contains("not found")
535 || message.contains("unknown revision")
536 || message.contains("unknown revision or path not in the working tree")
537 }
538 }
539
540 let message = error.localizedDescription.localizedLowercase
541 return message.contains("missing")
542 || message.contains("not found")
543 || message.contains("unknown revision")
529 error.matchesGraphQLErrorClassification(.notFound)
530 || error.matchesGraphQLErrorClassification(.unknownRevision)
531 || error.matchesGraphQLErrorClassification(.noRows)
532 || error.containsGraphQLErrorMessage("missing")
544533 }
545534}
Hutch/Views/Repositories/HgRepositorySettingsView.swift +23 −31
@@ -9,7 +9,6 @@ struct HgRepositorySettingsView: View {
99 @State private var viewModel: HgRepositorySettingsViewModel?
1010 @State private var showDeleteConfirmation = false
1111 @State private var pendingACLDeletion: HgACLEntry?
12 @State private var saveResultAlert: SaveResultAlert?
1312
1413 var body: some View {
1514 NavigationStack {
@@ -93,23 +92,26 @@ struct HgRepositorySettingsView: View {
9392 Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
9493 }
9594 }
96 .alert(item: $saveResultAlert) { alert in
97 Alert(
98 title: Text(alert.title),
99 message: Text(alert.message),
100 dismissButton: .default(Text("OK"))
101 )
102 }
10395 }
10496
10597 @ViewBuilder
10698 private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
107 Section("Info") {
108 LabeledContent("Name") {
109 Text(repository.name)
99 Section("Current Configuration") {
100 LabeledContent("Repository") {
101 Text("\(repository.owner.canonicalName)/\(repository.name)")
110102 .font(.body.monospaced())
111103 }
112104
105 LabeledContent("Forge") {
106 Text(repositoryForgeLabel(repository.service))
107 }
108
109 LabeledContent("Visibility") {
110 Text(repositoryVisibilityLabel(viewModel.editedVisibility))
111 }
112 }
113
114 Section("Repository Details") {
113115 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
114116 .lineLimit(3...6)
115117
@@ -121,11 +123,7 @@ struct HgRepositorySettingsView: View {
121123
122124 Button {
123125 Task {
124 let didSave = await viewModel.saveInfo()
125 saveResultAlert = SaveResultAlert(
126 title: didSave ? "Settings Updated" : "Couldn't Update Settings",
127 message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
128 )
126 _ = await viewModel.saveInfo()
129127 }
130128 } label: {
131129 if viewModel.isSavingInfo {
@@ -136,7 +134,7 @@ struct HgRepositorySettingsView: View {
136134 .frame(maxWidth: .infinity)
137135 }
138136 }
139 .disabled(viewModel.isSavingInfo)
137 .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
140138 }
141139 }
142140
@@ -202,16 +200,15 @@ struct HgRepositorySettingsView: View {
202200
203201 @ViewBuilder
204202 private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
205 Section("Features") {
203 Section("Sensitive Settings") {
206204 Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing)
205 Text("Changes stay pending until you save this section.")
206 .font(.caption)
207 .foregroundStyle(.secondary)
207208
208209 Button {
209210 Task {
210 let didSave = await viewModel.saveInfo()
211 saveResultAlert = SaveResultAlert(
212 title: didSave ? "Settings Updated" : "Couldn't Update Settings",
213 message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
214 )
211 _ = await viewModel.saveInfo()
215212 }
216213 } label: {
217214 if viewModel.isSavingInfo {
@@ -222,7 +219,7 @@ struct HgRepositorySettingsView: View {
222219 .frame(maxWidth: .infinity)
223220 }
224221 }
225 .disabled(viewModel.isSavingInfo)
222 .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
226223 }
227224 }
228225
@@ -261,13 +258,8 @@ struct HgRepositorySettingsView: View {
261258 }
262259 }
263260 .disabled(viewModel.isDeleting)
261 } header: {
262 Text("Danger Zone")
264263 }
265264 }
266
267 private struct SaveResultAlert: Identifiable {
268 let title: String
269 let message: String
270
271 var id: String { "\(title)-\(message)" }
272 }
273265}
Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift +30 −7
@@ -64,11 +64,14 @@ final class HgRepositorySettingsViewModel {
6464 let repositoryRid: String
6565 let repositoryName: String
6666 private let client: SRHTClient
67 private var initialDescription: String
68 private var initialVisibility: Visibility
6769
6870 var editedDescription: String
6971 var editedVisibility: Visibility
7072 var editedNonPublishing: Bool
7173 var isSavingInfo = false
74 private(set) var loadedNonPublishing = false
7275
7376 private(set) var acls: [HgACLEntry] = []
7477 private(set) var isLoadingACLs = false
@@ -82,12 +85,25 @@ final class HgRepositorySettingsViewModel {
8285 var didDelete = false
8386 var error: String?
8487
88 var normalizedEditedDescription: String {
89 editedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
90 }
91
92 var isInfoDirty: Bool {
93 normalizedEditedDescription != initialDescription ||
94 editedVisibility != initialVisibility ||
95 editedNonPublishing != loadedNonPublishing
96 }
97
8598 init(repository: RepositorySummary, client: SRHTClient) {
8699 self.repositoryId = repository.id
87100 self.repositoryRid = repository.rid
88101 self.repositoryName = repository.name
89102 self.client = client
90 self.editedDescription = repository.description ?? ""
103 let description = repository.description ?? ""
104 self.initialDescription = description
105 self.initialVisibility = repository.visibility
106 self.editedDescription = description
91107 self.editedVisibility = repository.visibility
92108 self.editedNonPublishing = false
93109 }
@@ -159,9 +175,14 @@ final class HgRepositorySettingsViewModel {
159175 )
160176
161177 if let repository = result.repository {
162 editedDescription = repository.description ?? ""
178 let description = repository.description ?? ""
179 initialDescription = description
180 initialVisibility = repository.visibility
181 editedDescription = description
163182 editedVisibility = repository.visibility
164 editedNonPublishing = repository.nonPublishing ?? false
183 let nonPublishing = repository.nonPublishing ?? false
184 editedNonPublishing = nonPublishing
185 loadedNonPublishing = nonPublishing
165186 }
166187 } catch {
167188 self.error = error.userFacingMessage
@@ -175,7 +196,7 @@ final class HgRepositorySettingsViewModel {
175196
176197 do {
177198 let input: [String: any Sendable] = [
178 "description": editedDescription,
199 "description": normalizedEditedDescription,
179200 "visibility": editedVisibility.rawValue,
180201 "nonPublishing": editedNonPublishing
181202 ]
@@ -185,6 +206,9 @@ final class HgRepositorySettingsViewModel {
185206 variables: ["id": repositoryId, "input": input],
186207 responseType: HgUpdateRepositoryResponse.self
187208 )
209 initialDescription = normalizedEditedDescription
210 initialVisibility = editedVisibility
211 loadedNonPublishing = editedNonPublishing
188212 return true
189213 } catch {
190214 self.error = error.userFacingMessage
@@ -237,9 +261,8 @@ final class HgRepositorySettingsViewModel {
237261 }
238262 newACLEntity = ""
239263 } catch {
240 let message = error.localizedDescription
241 if message.localizedCaseInsensitiveContains("No such repository or user found") {
242 self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first."
264 if error.matchesGraphQLErrorClassification(.serviceNotProvisioned) {
265 self.error = error.userFacingMessage
243266 } else {
244267 self.error = error.userFacingMessage
245268 }
Hutch/Views/Repositories/ReadmeView.swift +7 −4
@@ -54,10 +54,12 @@ struct ReadmeView: View {
5454 @ViewBuilder
5555 private var metadataSection: some View {
5656 VStack(alignment: .leading, spacing: 10) {
57 SummaryMetadataRow(
58 icon: "arrow.triangle.branch",
59 title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility)
60 )
57 if let branchLabel = repositoryPrimaryBranchLabel(for: viewModel.repository) {
58 SummaryMetadataRow(
59 icon: "arrow.triangle.branch",
60 title: branchLabel
61 )
62 }
6163
6264 if let readmePath = viewModel.readmePath {
6365 SummaryMetadataRow(
@@ -71,6 +73,7 @@ struct ReadmeView: View {
7173 private var repositoryDetailsSection: some View {
7274 DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
7375 VStack(alignment: .leading, spacing: 12) {
76 SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(viewModel.repository.service))
7477 SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
7578 SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
7679 SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true)
Hutch/Views/Repositories/RepositoryDetailViewModel.swift +1 −3
@@ -372,9 +372,7 @@ final class RepositoryDetailViewModel {
372372 }
373373
374374 private func isMissingGitReferenceError(_ error: Error) -> Bool {
375 guard let srhtError = error as? SRHTError else { return false }
376 guard case .graphQLErrors(let errors) = srhtError else { return false }
377 return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
375 error.matchesGraphQLErrorClassification(.missingReference)
378376 }
379377
380378 // MARK: - Artifacts
Hutch/Views/Repositories/RepositoryRowView.swift +1 −8
@@ -15,14 +15,7 @@ struct RepositoryRowView: View {
1515
1616 Spacer()
1717
18 if repository.service == .hg {
19 Text("HG")
20 .font(.caption2.weight(.medium))
21 .padding(.horizontal, 6)
22 .padding(.vertical, 2)
23 .background(Color.cyan.opacity(0.15), in: Capsule())
24 .foregroundStyle(.cyan)
25 }
18 RepositoryForgeBadge(service: repository.service)
2619
2720 if buildStatus != .none {
2821 RepositoryBuildStatusIndicator(status: buildStatus)
Hutch/Views/Repositories/RepositorySummarySupport.swift +46
@@ -46,6 +46,28 @@ func repositoryVisibilityLabel(_ visibility: Visibility) -> String {
4646 }
4747}
4848
49func repositoryForgeLabel(_ service: SRHTService) -> String {
50 switch service {
51 case .git:
52 return "GIT"
53 case .hg:
54 return "HG"
55 default:
56 return service.rawValue.uppercased()
57 }
58}
59
60func repositoryPrimaryBranchLabel(for repository: RepositorySummary, hgTipBranch: String? = nil) -> String? {
61 switch repository.service {
62 case .git:
63 return repository.defaultBranchName
64 case .hg:
65 return hgTipBranch ?? repository.defaultBranchName ?? "tip"
66 default:
67 return repository.defaultBranchName
68 }
69}
70
4971struct SummaryMetadataRow: View {
5072 let icon: String
5173 let title: String
@@ -85,3 +107,27 @@ struct SummaryDetailRow: View {
85107 }
86108 }
87109}
110
111struct RepositoryForgeBadge: View {
112 let service: SRHTService
113
114 var body: some View {
115 Text(repositoryForgeLabel(service))
116 .font(.caption2.weight(.medium))
117 .padding(.horizontal, 6)
118 .padding(.vertical, 2)
119 .background(color.opacity(0.15), in: Capsule())
120 .foregroundStyle(color)
121 }
122
123 private var color: Color {
124 switch service {
125 case .git:
126 .indigo
127 case .hg:
128 .cyan
129 default:
130 .secondary
131 }
132 }
133}
HutchTests/SRHTClientTests.swift +29
@@ -24,4 +24,33 @@ struct SRHTClientTests {
2424 Issue.record("Expected SRHTError.invalidAuthenticatedURL, got \(error).")
2525 }
2626 }
27
28 @Test
29 func graphQLErrorUserFacingMessagePreservesValidationDetails() {
30 let error = SRHTError.graphQLErrors([
31 GraphQLError(message: "A tracker named bugs already exists", locations: nil)
32 ])
33
34 #expect(error.userFacingMessage == "A tracker named bugs already exists")
35 }
36
37 @Test
38 func graphQLErrorUserFacingMessageClassifiesNotFoundResponses() {
39 let error = SRHTError.graphQLErrors([
40 GraphQLError(message: "reference not found", locations: nil)
41 ])
42
43 #expect(error.userFacingMessage == "That content is no longer available.")
44 #expect(error.matchesGraphQLErrorClassification(.missingReference))
45 }
46
47 @Test
48 func graphQLErrorUserFacingMessageClassifiesServiceProvisioningFailures() {
49 let error = SRHTError.graphQLErrors([
50 GraphQLError(message: "No such repository or user found", locations: nil)
51 ])
52
53 #expect(error.userFacingMessage == "That account needs to activate this SourceHut service before this action can succeed.")
54 #expect(error.matchesGraphQLErrorClassification(.serviceNotProvisioned))
55 }
2756}