Commit b2f3bce0c2
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | ||
| 515 | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 62; | |
| 518 | CURRENT_PROJECT_VERSION = 65; | |
| 519 | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | 520 | ENABLE_PREVIEWS = YES; |
| 521 | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | ||
| 532 | 532 | "$(inherited)", |
| 533 | 533 | "@executable_path/Frameworks", |
| 534 | 534 | ); |
| 535 | MARKETING_VERSION = 3.0.1; | |
| 535 | MARKETING_VERSION = 3.0.4; | |
| 536 | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | ||
| 552 | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 62; | |
| 555 | CURRENT_PROJECT_VERSION = 65; | |
| 556 | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | 557 | ENABLE_PREVIEWS = YES; |
| 558 | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | ||
| 569 | 569 | "$(inherited)", |
| 570 | 570 | "@executable_path/Frameworks", |
| 571 | 571 | ); |
| 572 | MARKETING_VERSION = 3.0.1; | |
| 572 | MARKETING_VERSION = 3.0.4; | |
| 573 | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | ||
| 632 | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 62; | |
| 635 | CURRENT_PROJECT_VERSION = 65; | |
| 636 | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | "@executable_path/Frameworks", |
| 643 | 643 | "@executable_path/../../Frameworks", |
| 644 | 644 | ); |
| 645 | MARKETING_VERSION = 3.0.1; | |
| 645 | MARKETING_VERSION = 3.0.4; | |
| 646 | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | ||
| 661 | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 62; | |
| 664 | CURRENT_PROJECT_VERSION = 65; | |
| 665 | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | ||
| 671 | 671 | "@executable_path/Frameworks", |
| 672 | 672 | "@executable_path/../../Frameworks", |
| 673 | 673 | ); |
| 674 | MARKETING_VERSION = 3.0.1; | |
| 674 | MARKETING_VERSION = 3.0.4; | |
| 675 | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 677 | SKIP_INSTALL = YES; |
Hutch/Networking/SRHTClient.swift +16 −17
| @@ -89,19 +89,12 @@ final class SRHTClient: Sendable { | ||
| 89 | 89 | throw SRHTError.unauthorized |
| 90 | 90 | } |
| 91 | 91 | 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) | |
| 97 | 93 | throw SRHTError.httpError(http.statusCode) |
| 98 | 94 | } |
| 99 | 95 | } |
| 100 | 96 | |
| 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) | |
| 105 | 98 | |
| 106 | 99 | // Decode GraphQL response envelope |
| 107 | 100 | let graphQLResponse: GraphQLResponse<T> |
| @@ -249,14 +242,12 @@ final class SRHTClient: Sendable { | ||
| 249 | 242 | throw SRHTError.unauthorized |
| 250 | 243 | } |
| 251 | 244 | if !(200...299).contains(http.statusCode) { |
| 245 | try throwGraphQLErrorsIfPresent(in: data) | |
| 252 | 246 | throw SRHTError.httpError(http.statusCode) |
| 253 | 247 | } |
| 254 | 248 | } |
| 255 | 249 | |
| 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) | |
| 260 | 251 | |
| 261 | 252 | let graphQLResponse: GraphQLResponse<T> |
| 262 | 253 | do { |
| @@ -381,14 +372,13 @@ final class SRHTClient: Sendable { | ||
| 381 | 372 | throw SRHTError.unauthorized |
| 382 | 373 | } |
| 383 | 374 | 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) | |
| 388 | 376 | throw SRHTError.httpError(http.statusCode) |
| 389 | 377 | } |
| 390 | 378 | } |
| 391 | 379 | |
| 380 | try throwGraphQLErrorsIfPresent(in: data) | |
| 381 | ||
| 392 | 382 | let graphQLResponse: GraphQLResponse<T> |
| 393 | 383 | do { |
| 394 | 384 | graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) |
| @@ -474,6 +464,7 @@ final class SRHTClient: Sendable { | ||
| 474 | 464 | throw SRHTError.unauthorized |
| 475 | 465 | } |
| 476 | 466 | if !(200...299).contains(http.statusCode) { |
| 467 | try throwGraphQLErrorsIfPresent(in: data) | |
| 477 | 468 | throw SRHTError.httpError(http.statusCode) |
| 478 | 469 | } |
| 479 | 470 | } |
| @@ -627,6 +618,14 @@ final class SRHTClient: Sendable { | ||
| 627 | 618 | // MARK: - Data Helper |
| 628 | 619 | |
| 629 | 620 | private 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 | 629 | static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool { |
| 631 | 630 | guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame, |
| 632 | 631 | let host = url.host?.lowercased() else { |
Hutch/Networking/SRHTError.swift +80 −8
| @@ -18,7 +18,7 @@ enum SRHTError: LocalizedError, Sendable { | ||
| 18 | 18 | var errorDescription: String? { |
| 19 | 19 | switch self { |
| 20 | 20 | case .graphQLErrors(let errors): |
| 21 | let messages = errors.map(\.message).joined(separator: "\n") | |
| 21 | let messages = errors.diagnosticSummary | |
| 22 | 22 | return "GraphQL error: \(messages)" |
| 23 | 23 | case .httpError(let code): |
| 24 | 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 | 37 | switch self { |
| 38 | 38 | 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: | |
| 41 | 41 | 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: | |
| 44 | 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 | 51 | case .httpError(let code): |
| 48 | 52 | if code == 401 { |
| 49 | 53 | return "Please sign in again." |
| @@ -105,7 +109,7 @@ enum SRHTError: LocalizedError, Sendable { | ||
| 105 | 109 | } |
| 106 | 110 | |
| 107 | 111 | extension Error { |
| 108 | var userFacingMessage: String { | |
| 112 | nonisolated var userFacingMessage: String { | |
| 109 | 113 | if let error = self as? SRHTError { |
| 110 | 114 | return error.userFacingMessage |
| 111 | 115 | } |
| @@ -125,6 +129,22 @@ extension Error { | ||
| 125 | 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 | 150 | /// A single error entry from the GraphQL `errors` array. |
| @@ -133,6 +153,58 @@ struct GraphQLError: Decodable, Sendable { | ||
| 133 | 153 | let locations: [GraphQLErrorLocation]? |
| 134 | 154 | } |
| 135 | 155 | |
| 156 | enum 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 | ||
| 168 | extension 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 | ||
| 136 | 208 | struct GraphQLErrorLocation: Decodable, Sendable { |
| 137 | 209 | let line: Int |
| 138 | 210 | let column: Int |
Hutch/Networking/SystemStatusRepository.swift +1 −50
| @@ -130,56 +130,7 @@ actor SystemStatusRepository { | ||
| 130 | 130 | } |
| 131 | 131 | |
| 132 | 132 | 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 | |
| 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 | 132 | .pickerStyle(.segmented) |
| 133 | .listRowBackground(Color.clear) | |
| 133 | .padding(.horizontal, 16) | |
| 134 | .padding(.top, 6) | |
| 135 | .padding(.bottom, 10) | |
| 134 | 136 | .listRowInsets(EdgeInsets()) |
| 135 | } | |
| 137 | .listRowBackground(Color.clear) | |
| 138 | .listRowSeparator(.hidden) | |
| 136 | 139 | |
| 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() | |
| 147 | 144 | } |
| 148 | ||
| 149 | if let note = job.note, !note.isEmpty { | |
| 145 | .contextMenu { | |
| 150 | 146 | Button { |
| 151 | appState.copyToPasteboard(note, label: "build note") | |
| 147 | appState.copyToPasteboard(String(job.id), label: "job ID") | |
| 152 | 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 { | |
| 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 | } | |
| 162 | 166 | } |
| 163 | 167 | } |
| 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") | |
| 170 | 176 | } |
| 171 | } label: { | |
| 172 | Label("Cancel", systemImage: "xmark.circle") | |
| 177 | .tint(.red) | |
| 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 { | |
| 183 | HStack { | |
| 184 | Spacer() | |
| 185 | ProgressView() | |
| 186 | Spacer() | |
| 185 | if viewModel.isLoadingMore { | |
| 186 | HStack { | |
| 187 | Spacer() | |
| 188 | ProgressView() | |
| 189 | Spacer() | |
| 190 | } | |
| 191 | .listRowSeparator(.hidden) | |
| 187 | 192 | } |
| 188 | .listRowSeparator(.hidden) | |
| 189 | 193 | } |
| 190 | 194 | } |
| 191 | 195 | .themedList() |
| 192 | 196 | .listStyle(.plain) |
| 197 | .listSectionSpacing(.compact) | |
| 193 | 198 | .searchable( |
| 194 | 199 | text: $vm.searchText, |
| 195 | 200 | placement: .navigationBarDrawer(displayMode: .always), |
Hutch/Views/Inbox/ThreadViewModel.swift +1 −4
| @@ -693,9 +693,6 @@ final class ThreadViewModel { | ||
| 693 | 693 | } |
| 694 | 694 | |
| 695 | 695 | 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) | |
| 700 | 697 | } |
| 701 | 698 | } |
Hutch/Views/Repositories/HgRepositoryDetailView.swift +45 −4
| @@ -7,6 +7,7 @@ struct HgRepositoryDetailView: View { | ||
| 7 | 7 | |
| 8 | 8 | @Environment(AppState.self) private var appState |
| 9 | 9 | @Environment(\.dismiss) private var dismiss |
| 10 | @Environment(\.openURL) private var openURL | |
| 10 | 11 | @Environment(\.colorScheme) private var colorScheme |
| 11 | 12 | |
| 12 | 13 | @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false |
| @@ -99,6 +100,7 @@ struct HgRepositoryDetailView: View { | ||
| 99 | 100 | async let log: () = vm.loadLog() |
| 100 | 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 | 173 | if canManageRepository { |
| 136 | 174 | Divider() |
| 137 | 175 | |
| @@ -227,10 +265,12 @@ struct HgRepositoryDetailView: View { | ||
| 227 | 265 | @ViewBuilder |
| 228 | 266 | private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { |
| 229 | 267 | 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 | } | |
| 234 | 274 | |
| 235 | 275 | if let readmePath = viewModel.readmePath { |
| 236 | 276 | SummaryMetadataRow( |
| @@ -244,6 +284,7 @@ struct HgRepositoryDetailView: View { | ||
| 244 | 284 | private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { |
| 245 | 285 | DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { |
| 246 | 286 | VStack(alignment: .leading, spacing: 12) { |
| 287 | SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(repository.service)) | |
| 247 | 288 | SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility)) |
| 248 | 289 | SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing") |
| 249 | 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 | 528 | 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") | |
| 544 | 533 | } |
| 545 | 534 | } |
Hutch/Views/Repositories/HgRepositorySettingsView.swift +23 −31
| @@ -9,7 +9,6 @@ struct HgRepositorySettingsView: View { | ||
| 9 | 9 | @State private var viewModel: HgRepositorySettingsViewModel? |
| 10 | 10 | @State private var showDeleteConfirmation = false |
| 11 | 11 | @State private var pendingACLDeletion: HgACLEntry? |
| 12 | @State private var saveResultAlert: SaveResultAlert? | |
| 13 | 12 | |
| 14 | 13 | var body: some View { |
| 15 | 14 | NavigationStack { |
| @@ -93,23 +92,26 @@ struct HgRepositorySettingsView: View { | ||
| 93 | 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 | 97 | @ViewBuilder |
| 106 | 98 | 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)") | |
| 110 | 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 | 115 | TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) |
| 114 | 116 | .lineLimit(3...6) |
| 115 | 117 | |
| @@ -121,11 +123,7 @@ struct HgRepositorySettingsView: View { | ||
| 121 | 123 | |
| 122 | 124 | Button { |
| 123 | 125 | 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() | |
| 129 | 127 | } |
| 130 | 128 | } label: { |
| 131 | 129 | if viewModel.isSavingInfo { |
| @@ -136,7 +134,7 @@ struct HgRepositorySettingsView: View { | ||
| 136 | 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 | 201 | @ViewBuilder |
| 204 | 202 | private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { |
| 205 | Section("Features") { | |
| 203 | Section("Sensitive Settings") { | |
| 206 | 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 | 209 | Button { |
| 209 | 210 | 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() | |
| 215 | 212 | } |
| 216 | 213 | } label: { |
| 217 | 214 | if viewModel.isSavingInfo { |
| @@ -222,7 +219,7 @@ struct HgRepositorySettingsView: View { | ||
| 222 | 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 | 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 | 64 | let repositoryRid: String |
| 65 | 65 | let repositoryName: String |
| 66 | 66 | private let client: SRHTClient |
| 67 | private var initialDescription: String | |
| 68 | private var initialVisibility: Visibility | |
| 67 | 69 | |
| 68 | 70 | var editedDescription: String |
| 69 | 71 | var editedVisibility: Visibility |
| 70 | 72 | var editedNonPublishing: Bool |
| 71 | 73 | var isSavingInfo = false |
| 74 | private(set) var loadedNonPublishing = false | |
| 72 | 75 | |
| 73 | 76 | private(set) var acls: [HgACLEntry] = [] |
| 74 | 77 | private(set) var isLoadingACLs = false |
| @@ -82,12 +85,25 @@ final class HgRepositorySettingsViewModel { | ||
| 82 | 85 | var didDelete = false |
| 83 | 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 | 98 | init(repository: RepositorySummary, client: SRHTClient) { |
| 86 | 99 | self.repositoryId = repository.id |
| 87 | 100 | self.repositoryRid = repository.rid |
| 88 | 101 | self.repositoryName = repository.name |
| 89 | 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 | 107 | self.editedVisibility = repository.visibility |
| 92 | 108 | self.editedNonPublishing = false |
| 93 | 109 | } |
| @@ -159,9 +175,14 @@ final class HgRepositorySettingsViewModel { | ||
| 159 | 175 | ) |
| 160 | 176 | |
| 161 | 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 | 182 | editedVisibility = repository.visibility |
| 164 | editedNonPublishing = repository.nonPublishing ?? false | |
| 183 | let nonPublishing = repository.nonPublishing ?? false | |
| 184 | editedNonPublishing = nonPublishing | |
| 185 | loadedNonPublishing = nonPublishing | |
| 165 | 186 | } |
| 166 | 187 | } catch { |
| 167 | 188 | self.error = error.userFacingMessage |
| @@ -175,7 +196,7 @@ final class HgRepositorySettingsViewModel { | ||
| 175 | 196 | |
| 176 | 197 | do { |
| 177 | 198 | let input: [String: any Sendable] = [ |
| 178 | "description": editedDescription, | |
| 199 | "description": normalizedEditedDescription, | |
| 179 | 200 | "visibility": editedVisibility.rawValue, |
| 180 | 201 | "nonPublishing": editedNonPublishing |
| 181 | 202 | ] |
| @@ -185,6 +206,9 @@ final class HgRepositorySettingsViewModel { | ||
| 185 | 206 | variables: ["id": repositoryId, "input": input], |
| 186 | 207 | responseType: HgUpdateRepositoryResponse.self |
| 187 | 208 | ) |
| 209 | initialDescription = normalizedEditedDescription | |
| 210 | initialVisibility = editedVisibility | |
| 211 | loadedNonPublishing = editedNonPublishing | |
| 188 | 212 | return true |
| 189 | 213 | } catch { |
| 190 | 214 | self.error = error.userFacingMessage |
| @@ -237,9 +261,8 @@ final class HgRepositorySettingsViewModel { | ||
| 237 | 261 | } |
| 238 | 262 | newACLEntity = "" |
| 239 | 263 | } 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 | |
| 243 | 266 | } else { |
| 244 | 267 | self.error = error.userFacingMessage |
| 245 | 268 | } |
Hutch/Views/Repositories/ReadmeView.swift +7 −4
| @@ -54,10 +54,12 @@ struct ReadmeView: View { | ||
| 54 | 54 | @ViewBuilder |
| 55 | 55 | private var metadataSection: some View { |
| 56 | 56 | 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 | } | |
| 61 | 63 | |
| 62 | 64 | if let readmePath = viewModel.readmePath { |
| 63 | 65 | SummaryMetadataRow( |
| @@ -71,6 +73,7 @@ struct ReadmeView: View { | ||
| 71 | 73 | private var repositoryDetailsSection: some View { |
| 72 | 74 | DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { |
| 73 | 75 | VStack(alignment: .leading, spacing: 12) { |
| 76 | SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(viewModel.repository.service)) | |
| 74 | 77 | SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility)) |
| 75 | 78 | SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true) |
| 76 | 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 | 374 | 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) | |
| 378 | 376 | } |
| 379 | 377 | |
| 380 | 378 | // MARK: - Artifacts |
Hutch/Views/Repositories/RepositoryRowView.swift +1 −8
| @@ -15,14 +15,7 @@ struct RepositoryRowView: View { | ||
| 15 | 15 | |
| 16 | 16 | Spacer() |
| 17 | 17 | |
| 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) | |
| 26 | 19 | |
| 27 | 20 | if buildStatus != .none { |
| 28 | 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 | |
| 49 | func 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 | ||
| 60 | func 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 | ||
| 49 | 71 | struct SummaryMetadataRow: View { |
| 50 | 72 | let icon: String |
| 51 | 73 | let title: String |
| @@ -85,3 +107,27 @@ struct SummaryDetailRow: View { | ||
| 85 | 107 | } |
| 86 | 108 | } |
| 87 | 109 | } |
| 110 | ||
| 111 | struct 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 | 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 | } |