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 @@
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 = 62; 518 CURRENT_PROJECT_VERSION = 65;
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 = 3.0.1; 535 MARKETING_VERSION = 3.0.4;
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 = 62; 555 CURRENT_PROJECT_VERSION = 65;
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 = 3.0.1; 572 MARKETING_VERSION = 3.0.4;
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 = 62; 635 CURRENT_PROJECT_VERSION = 65;
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 = 3.0.1; 645 MARKETING_VERSION = 3.0.4;
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 = 62; 664 CURRENT_PROJECT_VERSION = 65;
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 = 3.0.1; 674 MARKETING_VERSION = 3.0.4;
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/Networking/SRHTClient.swift +16 −17
@@ -89,19 +89,12 @@ final class SRHTClient: Sendable {
89 throw SRHTError.unauthorized 89 throw SRHTError.unauthorized
90 } 90 }
91 if !(200...299).contains(http.statusCode) { 91 if !(200...299).contains(http.statusCode) {
92 // Try to extract GraphQL errors from the response body even on non-2xx 92 try throwGraphQLErrorsIfPresent(in: data)
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 }
97 throw SRHTError.httpError(http.statusCode) 93 throw SRHTError.httpError(http.statusCode)
98 } 94 }
99 } 95 }
100 96
101 if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), 97 try throwGraphQLErrorsIfPresent(in: data)
102 let errors = errorEnvelope.errors, !errors.isEmpty {
103 throw SRHTError.graphQLErrors(errors)
104 }
105 98
106 // Decode GraphQL response envelope 99 // Decode GraphQL response envelope
107 let graphQLResponse: GraphQLResponse<T> 100 let graphQLResponse: GraphQLResponse<T>
@@ -249,14 +242,12 @@ final class SRHTClient: Sendable {
249 throw SRHTError.unauthorized 242 throw SRHTError.unauthorized
250 } 243 }
251 if !(200...299).contains(http.statusCode) { 244 if !(200...299).contains(http.statusCode) {
245 try throwGraphQLErrorsIfPresent(in: data)
252 throw SRHTError.httpError(http.statusCode) 246 throw SRHTError.httpError(http.statusCode)
253 } 247 }
254 } 248 }
255 249
256 if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), 250 try throwGraphQLErrorsIfPresent(in: data)
257 let errors = errorEnvelope.errors, !errors.isEmpty {
258 throw SRHTError.graphQLErrors(errors)
259 }
260 251
261 let graphQLResponse: GraphQLResponse<T> 252 let graphQLResponse: GraphQLResponse<T>
262 do { 253 do {
@@ -381,14 +372,13 @@ final class SRHTClient: Sendable {
381 throw SRHTError.unauthorized 372 throw SRHTError.unauthorized
382 } 373 }
383 if !(200...299).contains(http.statusCode) { 374 if !(200...299).contains(http.statusCode) {
384 if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), 375 try throwGraphQLErrorsIfPresent(in: data)
385 let errors = gqlResponse.errors, !errors.isEmpty {
386 throw SRHTError.graphQLErrors(errors)
387 }
388 throw SRHTError.httpError(http.statusCode) 376 throw SRHTError.httpError(http.statusCode)
389 } 377 }
390 } 378 }
391 379
380 try throwGraphQLErrorsIfPresent(in: data)
381
392 let graphQLResponse: GraphQLResponse<T> 382 let graphQLResponse: GraphQLResponse<T>
393 do { 383 do {
394 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) 384 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
@@ -474,6 +464,7 @@ final class SRHTClient: Sendable {
474 throw SRHTError.unauthorized 464 throw SRHTError.unauthorized
475 } 465 }
476 if !(200...299).contains(http.statusCode) { 466 if !(200...299).contains(http.statusCode) {
467 try throwGraphQLErrorsIfPresent(in: data)
477 throw SRHTError.httpError(http.statusCode) 468 throw SRHTError.httpError(http.statusCode)
478 } 469 }
479 } 470 }
@@ -627,6 +618,14 @@ final class SRHTClient: Sendable {
627// MARK: - Data Helper 618// MARK: - Data Helper
628 619
629private extension SRHTClient { 620private 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
630 static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool { 629 static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool {
631 guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame, 630 guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame,
632 let host = url.host?.lowercased() else { 631 let host = url.host?.lowercased() else {
Hutch/Networking/SRHTError.swift +80 −8
@@ -18,7 +18,7 @@ enum SRHTError: LocalizedError, Sendable {
18 var errorDescription: String? { 18 var errorDescription: String? {
19 switch self { 19 switch self {
20 case .graphQLErrors(let errors): 20 case .graphQLErrors(let errors):
21 let messages = errors.map(\.message).joined(separator: "\n") 21 let messages = errors.diagnosticSummary
22 return "GraphQL error: \(messages)" 22 return "GraphQL error: \(messages)"
23 case .httpError(let code): 23 case .httpError(let code):
24 return "Server returned HTTP \(code)." 24 return "Server returned HTTP \(code)."
@@ -33,17 +33,21 @@ enum SRHTError: LocalizedError, Sendable {
33 } 33 }
34 } 34 }
35 35
36 var userFacingMessage: String { 36 nonisolated var userFacingMessage: String {
37 switch self { 37 switch self {
38 case .graphQLErrors(let errors): 38 case .graphQLErrors(let errors):
39 let firstMessage = errors.first?.message.lowercased() ?? "" 39 switch errors.classification {
40 if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") { 40 case .unauthorized, .forbidden:
41 return "You do not have permission to do that." 41 return "You do not have permission to do that."
42 } 42 case .notFound, .noRows, .missingReference, .unknownRevision:
43 if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") {
44 return "That content is no longer available." 43 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."
45 } 50 }
46 return "Something went wrong. Please try again."
47 case .httpError(let code): 51 case .httpError(let code):
48 if code == 401 { 52 if code == 401 {
49 return "Please sign in again." 53 return "Please sign in again."
@@ -105,7 +109,7 @@ enum SRHTError: LocalizedError, Sendable {
105} 109}
106 110
107extension Error { 111extension Error {
108 var userFacingMessage: String { 112 nonisolated var userFacingMessage: String {
109 if let error = self as? SRHTError { 113 if let error = self as? SRHTError {
110 return error.userFacingMessage 114 return error.userFacingMessage
111 } 115 }
@@ -125,6 +129,22 @@ extension Error {
125 return "Something went wrong. Please try again." 129 return "Something went wrong. Please try again."
126 } 130 }
127 } 131 }
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 }
128} 148}
129 149
130/// A single error entry from the GraphQL `errors` array. 150/// A single error entry from the GraphQL `errors` array.
@@ -133,6 +153,58 @@ struct GraphQLError: Decodable, Sendable {
133 let locations: [GraphQLErrorLocation]? 153 let locations: [GraphQLErrorLocation]?
134} 154}
135 155
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
136struct GraphQLErrorLocation: Decodable, Sendable { 208struct GraphQLErrorLocation: Decodable, Sendable {
137 let line: Int 209 let line: Int
138 let column: Int 210 let column: Int
Hutch/Networking/SystemStatusRepository.swift +1 −50
@@ -130,56 +130,7 @@ actor SystemStatusRepository {
130 } 130 }
131 131
132 private func refreshErrorMessage(from error: any Error) -> String { 132 private func refreshErrorMessage(from error: any Error) -> String {
133 if let error = error as? SRHTError { 133 error.userFacingMessage
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 }
183 } 134 }
184} 135}
185 136
Hutch/Views/Builds/BuildListView.swift +46 −41
@@ -130,66 +130,71 @@ struct BuildListView: View {
130 } 130 }
131 } 131 }
132 .pickerStyle(.segmented) 132 .pickerStyle(.segmented)
133 .listRowBackground(Color.clear) 133 .padding(.horizontal, 16)
134 .padding(.top, 6)
135 .padding(.bottom, 10)
134 .listRowInsets(EdgeInsets()) 136 .listRowInsets(EdgeInsets())
135 } 137 .listRowBackground(Color.clear)
138 .listRowSeparator(.hidden)
136 139
137 ForEach(viewModel.filteredJobs) { job in 140 ForEach(viewModel.filteredJobs) { job in
138 NavigationLink(value: job) { 141 NavigationLink(value: job) {
139 BuildRowView(job: job) 142 BuildRowView(job: job)
140 .equatable() 143 .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")
147 } 144 }
148 145 .contextMenu {
149 if let note = job.note, !note.isEmpty {
150 Button { 146 Button {
151 appState.copyToPasteboard(note, label: "build note") 147 appState.copyToPasteboard(String(job.id), label: "job ID")
152 } label: { 148 } label: {
153 Label("Copy Note", systemImage: "text.alignleft") 149 Label("Copy Job ID", systemImage: "doc.on.doc")
154 } 150 }
155 }
156 151
157 if !job.tags.isEmpty { 152 if let note = job.note, !note.isEmpty {
158 Button { 153 Button {
159 appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags") 154 appState.copyToPasteboard(note, label: "build note")
160 } label: { 155 } label: {
161 Label("Copy Tags", systemImage: "tag") 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 }
162 } 166 }
163 } 167 }
164 } 168 .swipeActions(edge: .leading, allowsFullSwipe: true) {
165 .swipeActions(edge: .leading, allowsFullSwipe: true) { 169 if swipeActionsEnabled, job.status.isCancellable {
166 if swipeActionsEnabled, job.status.isCancellable { 170 Button {
167 Button { 171 Task {
168 Task { 172 await viewModel.cancelJob(job)
169 await viewModel.cancelJob(job) 173 }
174 } label: {
175 Label("Cancel", systemImage: "xmark.circle")
170 } 176 }
171 } label: { 177 .tint(.red)
172 Label("Cancel", systemImage: "xmark.circle")
173 } 178 }
174 .tint(.red) 179 }
180 .task {
181 await viewModel.loadMoreIfNeeded(currentItem: job)
175 } 182 }
176 } 183 }
177 .task {
178 await viewModel.loadMoreIfNeeded(currentItem: job)
179 }
180 }
181 184
182 if viewModel.isLoadingMore { 185 if viewModel.isLoadingMore {
183 HStack { 186 HStack {
184 Spacer() 187 Spacer()
185 ProgressView() 188 ProgressView()
186 Spacer() 189 Spacer()
190 }
191 .listRowSeparator(.hidden)
187 } 192 }
188 .listRowSeparator(.hidden)
189 } 193 }
190 } 194 }
191 .themedList() 195 .themedList()
192 .listStyle(.plain) 196 .listStyle(.plain)
197 .listSectionSpacing(.compact)
193 .searchable( 198 .searchable(
194 text: $vm.searchText, 199 text: $vm.searchText,
195 placement: .navigationBarDrawer(displayMode: .always), 200 placement: .navigationBarDrawer(displayMode: .always),
Hutch/Views/Inbox/ThreadViewModel.swift +1 −4
@@ -693,9 +693,6 @@ final class ThreadViewModel {
693 } 693 }
694 694
695 private static func isRecoverableNoRows(_ error: Error) -> Bool { 695 private static func isRecoverableNoRows(_ error: Error) -> Bool {
696 guard case let SRHTError.graphQLErrors(errors) = error else { 696 error.matchesGraphQLErrorClassification(.noRows)
697 return false
698 }
699 return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") }
700 } 697 }
701} 698}
Hutch/Views/Repositories/HgRepositoryDetailView.swift +45 −4
@@ -7,6 +7,7 @@ struct HgRepositoryDetailView: View {
7 7
8 @Environment(AppState.self) private var appState 8 @Environment(AppState.self) private var appState
9 @Environment(\.dismiss) private var dismiss 9 @Environment(\.dismiss) private var dismiss
10 @Environment(\.openURL) private var openURL
10 @Environment(\.colorScheme) private var colorScheme 11 @Environment(\.colorScheme) private var colorScheme
11 12
12 @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false 13 @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false
@@ -99,6 +100,7 @@ struct HgRepositoryDetailView: View {
99 async let log: () = vm.loadLog() 100 async let log: () = vm.loadLog()
100 _ = await (summary, browse, log) 101 _ = await (summary, browse, log)
101 } 102 }
103 RecentActivityStore.recordRepository(repository, defaults: appState.accountDefaults)
102 } 104 }
103 } 105 }
104 106
@@ -132,6 +134,42 @@ struct HgRepositoryDetailView: View {
132 } 134 }
133 } 135 }
134 136
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
135 if canManageRepository { 173 if canManageRepository {
136 Divider() 174 Divider()
137 175
@@ -227,10 +265,12 @@ struct HgRepositoryDetailView: View {
227 @ViewBuilder 265 @ViewBuilder
228 private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { 266 private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
229 VStack(alignment: .leading, spacing: 10) { 267 VStack(alignment: .leading, spacing: 10) {
230 SummaryMetadataRow( 268 if let branchLabel = repositoryPrimaryBranchLabel(for: repository, hgTipBranch: viewModel.tip?.branch) {
231 icon: "arrow.triangle.branch", 269 SummaryMetadataRow(
232 title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility) 270 icon: "arrow.triangle.branch",
233 ) 271 title: branchLabel
272 )
273 }
234 274
235 if let readmePath = viewModel.readmePath { 275 if let readmePath = viewModel.readmePath {
236 SummaryMetadataRow( 276 SummaryMetadataRow(
@@ -244,6 +284,7 @@ struct HgRepositoryDetailView: View {
244 private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { 284 private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
245 DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { 285 DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
246 VStack(alignment: .leading, spacing: 12) { 286 VStack(alignment: .leading, spacing: 12) {
287 SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(repository.service))
247 SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility)) 288 SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility))
248 SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing") 289 SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
249 SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true) 290 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 {
526 } 526 }
527 527
528 private func isEmptyRepositoryError(_ error: Error) -> Bool { 528 private func isEmptyRepositoryError(_ error: Error) -> Bool {
529 if let srhtError = error as? SRHTError, 529 error.matchesGraphQLErrorClassification(.notFound)
530 case .graphQLErrors(let errors) = srhtError { 530 || error.matchesGraphQLErrorClassification(.unknownRevision)
531 return errors.contains { 531 || error.matchesGraphQLErrorClassification(.noRows)
532 let message = $0.message.localizedLowercase 532 || error.containsGraphQLErrorMessage("missing")
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")
544 } 533 }
545} 534}
Hutch/Views/Repositories/HgRepositorySettingsView.swift +23 −31
@@ -9,7 +9,6 @@ struct HgRepositorySettingsView: View {
9 @State private var viewModel: HgRepositorySettingsViewModel? 9 @State private var viewModel: HgRepositorySettingsViewModel?
10 @State private var showDeleteConfirmation = false 10 @State private var showDeleteConfirmation = false
11 @State private var pendingACLDeletion: HgACLEntry? 11 @State private var pendingACLDeletion: HgACLEntry?
12 @State private var saveResultAlert: SaveResultAlert?
13 12
14 var body: some View { 13 var body: some View {
15 NavigationStack { 14 NavigationStack {
@@ -93,23 +92,26 @@ struct HgRepositorySettingsView: View {
93 Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.") 92 Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
94 } 93 }
95 } 94 }
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 }
103 } 95 }
104 96
105 @ViewBuilder 97 @ViewBuilder
106 private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { 98 private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
107 Section("Info") { 99 Section("Current Configuration") {
108 LabeledContent("Name") { 100 LabeledContent("Repository") {
109 Text(repository.name) 101 Text("\(repository.owner.canonicalName)/\(repository.name)")
110 .font(.body.monospaced()) 102 .font(.body.monospaced())
111 } 103 }
112 104
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") {
113 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) 115 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
114 .lineLimit(3...6) 116 .lineLimit(3...6)
115 117
@@ -121,11 +123,7 @@ struct HgRepositorySettingsView: View {
121 123
122 Button { 124 Button {
123 Task { 125 Task {
124 let didSave = await viewModel.saveInfo() 126 _ = 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 )
129 } 127 }
130 } label: { 128 } label: {
131 if viewModel.isSavingInfo { 129 if viewModel.isSavingInfo {
@@ -136,7 +134,7 @@ struct HgRepositorySettingsView: View {
136 .frame(maxWidth: .infinity) 134 .frame(maxWidth: .infinity)
137 } 135 }
138 } 136 }
139 .disabled(viewModel.isSavingInfo) 137 .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
140 } 138 }
141 } 139 }
142 140
@@ -202,16 +200,15 @@ struct HgRepositorySettingsView: View {
202 200
203 @ViewBuilder 201 @ViewBuilder
204 private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { 202 private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
205 Section("Features") { 203 Section("Sensitive Settings") {
206 Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing) 204 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)
207 208
208 Button { 209 Button {
209 Task { 210 Task {
210 let didSave = await viewModel.saveInfo() 211 _ = 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 )
215 } 212 }
216 } label: { 213 } label: {
217 if viewModel.isSavingInfo { 214 if viewModel.isSavingInfo {
@@ -222,7 +219,7 @@ struct HgRepositorySettingsView: View {
222 .frame(maxWidth: .infinity) 219 .frame(maxWidth: .infinity)
223 } 220 }
224 } 221 }
225 .disabled(viewModel.isSavingInfo) 222 .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
226 } 223 }
227 } 224 }
228 225
@@ -261,13 +258,8 @@ struct HgRepositorySettingsView: View {
261 } 258 }
262 } 259 }
263 .disabled(viewModel.isDeleting) 260 .disabled(viewModel.isDeleting)
261 } header: {
262 Text("Danger Zone")
264 } 263 }
265 } 264 }
266
267 private struct SaveResultAlert: Identifiable {
268 let title: String
269 let message: String
270
271 var id: String { "\(title)-\(message)" }
272 }
273} 265}
Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift +30 −7
@@ -64,11 +64,14 @@ final class HgRepositorySettingsViewModel {
64 let repositoryRid: String 64 let repositoryRid: String
65 let repositoryName: String 65 let repositoryName: String
66 private let client: SRHTClient 66 private let client: SRHTClient
67 private var initialDescription: String
68 private var initialVisibility: Visibility
67 69
68 var editedDescription: String 70 var editedDescription: String
69 var editedVisibility: Visibility 71 var editedVisibility: Visibility
70 var editedNonPublishing: Bool 72 var editedNonPublishing: Bool
71 var isSavingInfo = false 73 var isSavingInfo = false
74 private(set) var loadedNonPublishing = false
72 75
73 private(set) var acls: [HgACLEntry] = [] 76 private(set) var acls: [HgACLEntry] = []
74 private(set) var isLoadingACLs = false 77 private(set) var isLoadingACLs = false
@@ -82,12 +85,25 @@ final class HgRepositorySettingsViewModel {
82 var didDelete = false 85 var didDelete = false
83 var error: String? 86 var error: String?
84 87
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
85 init(repository: RepositorySummary, client: SRHTClient) { 98 init(repository: RepositorySummary, client: SRHTClient) {
86 self.repositoryId = repository.id 99 self.repositoryId = repository.id
87 self.repositoryRid = repository.rid 100 self.repositoryRid = repository.rid
88 self.repositoryName = repository.name 101 self.repositoryName = repository.name
89 self.client = client 102 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
91 self.editedVisibility = repository.visibility 107 self.editedVisibility = repository.visibility
92 self.editedNonPublishing = false 108 self.editedNonPublishing = false
93 } 109 }
@@ -159,9 +175,14 @@ final class HgRepositorySettingsViewModel {
159 ) 175 )
160 176
161 if let repository = result.repository { 177 if let repository = result.repository {
162 editedDescription = repository.description ?? "" 178 let description = repository.description ?? ""
179 initialDescription = description
180 initialVisibility = repository.visibility
181 editedDescription = description
163 editedVisibility = repository.visibility 182 editedVisibility = repository.visibility
164 editedNonPublishing = repository.nonPublishing ?? false 183 let nonPublishing = repository.nonPublishing ?? false
184 editedNonPublishing = nonPublishing
185 loadedNonPublishing = nonPublishing
165 } 186 }
166 } catch { 187 } catch {
167 self.error = error.userFacingMessage 188 self.error = error.userFacingMessage
@@ -175,7 +196,7 @@ final class HgRepositorySettingsViewModel {
175 196
176 do { 197 do {
177 let input: [String: any Sendable] = [ 198 let input: [String: any Sendable] = [
178 "description": editedDescription, 199 "description": normalizedEditedDescription,
179 "visibility": editedVisibility.rawValue, 200 "visibility": editedVisibility.rawValue,
180 "nonPublishing": editedNonPublishing 201 "nonPublishing": editedNonPublishing
181 ] 202 ]
@@ -185,6 +206,9 @@ final class HgRepositorySettingsViewModel {
185 variables: ["id": repositoryId, "input": input], 206 variables: ["id": repositoryId, "input": input],
186 responseType: HgUpdateRepositoryResponse.self 207 responseType: HgUpdateRepositoryResponse.self
187 ) 208 )
209 initialDescription = normalizedEditedDescription
210 initialVisibility = editedVisibility
211 loadedNonPublishing = editedNonPublishing
188 return true 212 return true
189 } catch { 213 } catch {
190 self.error = error.userFacingMessage 214 self.error = error.userFacingMessage
@@ -237,9 +261,8 @@ final class HgRepositorySettingsViewModel {
237 } 261 }
238 newACLEntity = "" 262 newACLEntity = ""
239 } catch { 263 } catch {
240 let message = error.localizedDescription 264 if error.matchesGraphQLErrorClassification(.serviceNotProvisioned) {
241 if message.localizedCaseInsensitiveContains("No such repository or user found") { 265 self.error = error.userFacingMessage
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."
243 } else { 266 } else {
244 self.error = error.userFacingMessage 267 self.error = error.userFacingMessage
245 } 268 }
Hutch/Views/Repositories/ReadmeView.swift +7 −4
@@ -54,10 +54,12 @@ struct ReadmeView: View {
54 @ViewBuilder 54 @ViewBuilder
55 private var metadataSection: some View { 55 private var metadataSection: some View {
56 VStack(alignment: .leading, spacing: 10) { 56 VStack(alignment: .leading, spacing: 10) {
57 SummaryMetadataRow( 57 if let branchLabel = repositoryPrimaryBranchLabel(for: viewModel.repository) {
58 icon: "arrow.triangle.branch", 58 SummaryMetadataRow(
59 title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility) 59 icon: "arrow.triangle.branch",
60 ) 60 title: branchLabel
61 )
62 }
61 63
62 if let readmePath = viewModel.readmePath { 64 if let readmePath = viewModel.readmePath {
63 SummaryMetadataRow( 65 SummaryMetadataRow(
@@ -71,6 +73,7 @@ struct ReadmeView: View {
71 private var repositoryDetailsSection: some View { 73 private var repositoryDetailsSection: some View {
72 DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { 74 DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
73 VStack(alignment: .leading, spacing: 12) { 75 VStack(alignment: .leading, spacing: 12) {
76 SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(viewModel.repository.service))
74 SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility)) 77 SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
75 SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true) 78 SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
76 SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true) 79 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 {
372 } 372 }
373 373
374 private func isMissingGitReferenceError(_ error: Error) -> Bool { 374 private func isMissingGitReferenceError(_ error: Error) -> Bool {
375 guard let srhtError = error as? SRHTError else { return false } 375 error.matchesGraphQLErrorClassification(.missingReference)
376 guard case .graphQLErrors(let errors) = srhtError else { return false }
377 return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
378 } 376 }
379 377
380 // MARK: - Artifacts 378 // MARK: - Artifacts
Hutch/Views/Repositories/RepositoryRowView.swift +1 −8
@@ -15,14 +15,7 @@ struct RepositoryRowView: View {
15 15
16 Spacer() 16 Spacer()
17 17
18 if repository.service == .hg { 18 RepositoryForgeBadge(service: repository.service)
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 }
26 19
27 if buildStatus != .none { 20 if buildStatus != .none {
28 RepositoryBuildStatusIndicator(status: buildStatus) 21 RepositoryBuildStatusIndicator(status: buildStatus)
Hutch/Views/Repositories/RepositorySummarySupport.swift +46
@@ -46,6 +46,28 @@ func repositoryVisibilityLabel(_ visibility: Visibility) -> String {
46 } 46 }
47} 47}
48 48
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
49struct SummaryMetadataRow: View { 71struct SummaryMetadataRow: View {
50 let icon: String 72 let icon: String
51 let title: String 73 let title: String
@@ -85,3 +107,27 @@ struct SummaryDetailRow: View {
85 } 107 }
86 } 108 }
87} 109}
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 {
24 Issue.record("Expected SRHTError.invalidAuthenticatedURL, got \(error).") 24 Issue.record("Expected SRHTError.invalidAuthenticatedURL, got \(error).")
25 } 25 }
26 } 26 }
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 }
27} 56}