Commit b2f3bce0c2
Unsigned
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 | ||
| 629 | private extension SRHTClient { | 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 | 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 | ||
| 107 | extension Error { | 111 | extension 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 | ||
| 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 | struct GraphQLErrorLocation: Decodable, Sendable { | 208 | struct 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 | ||
| 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 | struct SummaryMetadataRow: View { | 71 | struct 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 | |||
| 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 | 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 | } |