diff options
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/Networking/SRHTClient.swift | 33 | ||||
| -rw-r--r-- | Hutch/Networking/SRHTError.swift | 88 | ||||
| -rw-r--r-- | Hutch/Networking/SystemStatusRepository.swift | 51 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListView.swift | 87 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/ThreadViewModel.swift | 5 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/HgRepositoryDetailView.swift | 49 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift | 19 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/HgRepositorySettingsView.swift | 54 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift | 37 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/ReadmeView.swift | 11 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailViewModel.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryRowView.swift | 9 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySummarySupport.swift | 46 | ||||
| -rw-r--r-- | HutchTests/SRHTClientTests.swift | 29 |
15 files changed, 338 insertions, 200 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index e8b6b96..2d7ef6d 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -515,7 +515,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 62; + CURRENT_PROJECT_VERSION = 65; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -532,7 +532,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0.1; + MARKETING_VERSION = 3.0.4; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -552,7 +552,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 62; + CURRENT_PROJECT_VERSION = 65; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0.1; + MARKETING_VERSION = 3.0.4; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -632,7 +632,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 62; + CURRENT_PROJECT_VERSION = 65; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -642,7 +642,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.0.1; + MARKETING_VERSION = 3.0.4; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -661,7 +661,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 62; + CURRENT_PROJECT_VERSION = 65; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -671,7 +671,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.0.1; + MARKETING_VERSION = 3.0.4; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index 1b8b9b3..7e1f619 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -89,19 +89,12 @@ final class SRHTClient: Sendable { throw SRHTError.unauthorized } if !(200...299).contains(http.statusCode) { - // Try to extract GraphQL errors from the response body even on non-2xx - if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), - let errors = gqlResponse.errors, !errors.isEmpty { - throw SRHTError.graphQLErrors(errors) - } + try throwGraphQLErrorsIfPresent(in: data) throw SRHTError.httpError(http.statusCode) } } - if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), - let errors = errorEnvelope.errors, !errors.isEmpty { - throw SRHTError.graphQLErrors(errors) - } + try throwGraphQLErrorsIfPresent(in: data) // Decode GraphQL response envelope let graphQLResponse: GraphQLResponse<T> @@ -249,14 +242,12 @@ final class SRHTClient: Sendable { throw SRHTError.unauthorized } if !(200...299).contains(http.statusCode) { + try throwGraphQLErrorsIfPresent(in: data) throw SRHTError.httpError(http.statusCode) } } - if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), - let errors = errorEnvelope.errors, !errors.isEmpty { - throw SRHTError.graphQLErrors(errors) - } + try throwGraphQLErrorsIfPresent(in: data) let graphQLResponse: GraphQLResponse<T> do { @@ -381,14 +372,13 @@ final class SRHTClient: Sendable { throw SRHTError.unauthorized } if !(200...299).contains(http.statusCode) { - if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), - let errors = gqlResponse.errors, !errors.isEmpty { - throw SRHTError.graphQLErrors(errors) - } + try throwGraphQLErrorsIfPresent(in: data) throw SRHTError.httpError(http.statusCode) } } + try throwGraphQLErrorsIfPresent(in: data) + let graphQLResponse: GraphQLResponse<T> do { graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) @@ -474,6 +464,7 @@ final class SRHTClient: Sendable { throw SRHTError.unauthorized } if !(200...299).contains(http.statusCode) { + try throwGraphQLErrorsIfPresent(in: data) throw SRHTError.httpError(http.statusCode) } } @@ -627,6 +618,14 @@ final class SRHTClient: Sendable { // MARK: - Data Helper private extension SRHTClient { + func throwGraphQLErrorsIfPresent(in data: Data) throws { + if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), + let errors = envelope.errors, + !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + } + static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool { guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame, let host = url.host?.lowercased() else { diff --git a/Hutch/Networking/SRHTError.swift b/Hutch/Networking/SRHTError.swift index b9fc4c8..e946774 100644 --- a/Hutch/Networking/SRHTError.swift +++ b/Hutch/Networking/SRHTError.swift @@ -18,7 +18,7 @@ enum SRHTError: LocalizedError, Sendable { var errorDescription: String? { switch self { case .graphQLErrors(let errors): - let messages = errors.map(\.message).joined(separator: "\n") + let messages = errors.diagnosticSummary return "GraphQL error: \(messages)" case .httpError(let code): return "Server returned HTTP \(code)." @@ -33,17 +33,21 @@ enum SRHTError: LocalizedError, Sendable { } } - var userFacingMessage: String { + nonisolated var userFacingMessage: String { switch self { case .graphQLErrors(let errors): - let firstMessage = errors.first?.message.lowercased() ?? "" - if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") { + switch errors.classification { + case .unauthorized, .forbidden: return "You do not have permission to do that." - } - if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") { + case .notFound, .noRows, .missingReference, .unknownRevision: return "That content is no longer available." + case .serviceNotProvisioned: + return "That account needs to activate this SourceHut service before this action can succeed." + case .validation: + return errors.primaryMessage ?? "Please review your changes and try again." + case .other: + return "Something went wrong. Please try again." } - return "Something went wrong. Please try again." case .httpError(let code): if code == 401 { return "Please sign in again." @@ -105,7 +109,7 @@ enum SRHTError: LocalizedError, Sendable { } extension Error { - var userFacingMessage: String { + nonisolated var userFacingMessage: String { if let error = self as? SRHTError { return error.userFacingMessage } @@ -125,6 +129,22 @@ extension Error { return "Something went wrong. Please try again." } } + + nonisolated var graphQLErrors: [GraphQLError]? { + guard let srhtError = self as? SRHTError, + case let SRHTError.graphQLErrors(errors) = srhtError else { + return nil + } + return errors + } + + nonisolated func matchesGraphQLErrorClassification(_ classification: GraphQLErrorClassification) -> Bool { + graphQLErrors?.classification == classification + } + + nonisolated func containsGraphQLErrorMessage(_ fragment: String) -> Bool { + graphQLErrors?.containsMessage(fragment) == true + } } /// A single error entry from the GraphQL `errors` array. @@ -133,6 +153,58 @@ struct GraphQLError: Decodable, Sendable { let locations: [GraphQLErrorLocation]? } +enum GraphQLErrorClassification: Sendable { + case unauthorized + case forbidden + case notFound + case noRows + case missingReference + case unknownRevision + case serviceNotProvisioned + case validation + case other +} + +extension Array where Element == GraphQLError { + nonisolated var classification: GraphQLErrorClassification { + if containsMessage("unauthorized") { return .unauthorized } + if containsMessage("forbidden") { return .forbidden } + if containsMessage("reference not found") { return .missingReference } + if containsMessage("no rows in result set") { return .noRows } + if containsMessage("unknown revision") || containsMessage("path not in the working tree") { + return .unknownRevision + } + if containsMessage("not found") || containsMessage("no such") || containsMessage("missing revision") { + return .notFound + } + if containsMessage("no such repository or user found") { + return .serviceNotProvisioned + } + if let primaryMessage, !primaryMessage.isEmpty { + return .validation + } + return .other + } + + nonisolated var primaryMessage: String? { + let candidates = map(\.message) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return candidates.first + } + + nonisolated var diagnosticSummary: String { + map(\.message) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + } + + nonisolated func containsMessage(_ fragment: String) -> Bool { + contains { $0.message.localizedCaseInsensitiveContains(fragment) } + } +} + struct GraphQLErrorLocation: Decodable, Sendable { let line: Int let column: Int diff --git a/Hutch/Networking/SystemStatusRepository.swift b/Hutch/Networking/SystemStatusRepository.swift index 5f51315..028c64e 100644 --- a/Hutch/Networking/SystemStatusRepository.swift +++ b/Hutch/Networking/SystemStatusRepository.swift @@ -130,56 +130,7 @@ actor SystemStatusRepository { } private func refreshErrorMessage(from error: any Error) -> String { - if let error = error as? SRHTError { - switch error { - case .graphQLErrors(let errors): - let firstMessage = errors.first?.message.lowercased() ?? "" - if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") { - return "You do not have permission to do that." - } - if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") { - return "That content is no longer available." - } - return "Something went wrong. Please try again." - case .httpError(let code): - if code == 401 { - return "Please sign in again." - } - if code == 403 { - return "You do not have permission to do that." - } - if code == 404 { - return "That content is no longer available." - } - if (500...599).contains(code) { - return "The server is unavailable right now. Please try again." - } - return "Something went wrong. Please try again." - case .invalidAuthenticatedURL: - return "That request could not be completed." - case .decodingError: - return "The response could not be loaded right now." - case .networkError(let underlyingError): - return refreshErrorMessage(from: underlyingError) - case .unauthorized: - return "Please sign in again." - } - } - - let nsError = error as NSError - switch nsError.code { - case NSURLErrorNotConnectedToInternet, - NSURLErrorNetworkConnectionLost, - NSURLErrorTimedOut, - NSURLErrorCannotFindHost, - NSURLErrorCannotConnectToHost, - NSURLErrorDNSLookupFailed, - NSURLErrorInternationalRoamingOff, - NSURLErrorDataNotAllowed: - return "Check your connection and try again." - default: - return "Something went wrong. Please try again." - } + error.userFacingMessage } } diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift index d525918..f4647e4 100644 --- a/Hutch/Views/Builds/BuildListView.swift +++ b/Hutch/Views/Builds/BuildListView.swift @@ -130,66 +130,71 @@ struct BuildListView: View { } } .pickerStyle(.segmented) - .listRowBackground(Color.clear) + .padding(.horizontal, 16) + .padding(.top, 6) + .padding(.bottom, 10) .listRowInsets(EdgeInsets()) - } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) - ForEach(viewModel.filteredJobs) { job in - NavigationLink(value: job) { - BuildRowView(job: job) - .equatable() - } - .contextMenu { - Button { - appState.copyToPasteboard(String(job.id), label: "job ID") - } label: { - Label("Copy Job ID", systemImage: "doc.on.doc") + ForEach(viewModel.filteredJobs) { job in + NavigationLink(value: job) { + BuildRowView(job: job) + .equatable() } - - if let note = job.note, !note.isEmpty { + .contextMenu { Button { - appState.copyToPasteboard(note, label: "build note") + appState.copyToPasteboard(String(job.id), label: "job ID") } label: { - Label("Copy Note", systemImage: "text.alignleft") + Label("Copy Job ID", systemImage: "doc.on.doc") } - } - if !job.tags.isEmpty { - Button { - appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags") - } label: { - Label("Copy Tags", systemImage: "tag") + if let note = job.note, !note.isEmpty { + Button { + appState.copyToPasteboard(note, label: "build note") + } label: { + Label("Copy Note", systemImage: "text.alignleft") + } + } + + if !job.tags.isEmpty { + Button { + appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags") + } label: { + Label("Copy Tags", systemImage: "tag") + } } } - } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - if swipeActionsEnabled, job.status.isCancellable { - Button { - Task { - await viewModel.cancelJob(job) + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if swipeActionsEnabled, job.status.isCancellable { + Button { + Task { + await viewModel.cancelJob(job) + } + } label: { + Label("Cancel", systemImage: "xmark.circle") } - } label: { - Label("Cancel", systemImage: "xmark.circle") + .tint(.red) } - .tint(.red) + } + .task { + await viewModel.loadMoreIfNeeded(currentItem: job) } } - .task { - await viewModel.loadMoreIfNeeded(currentItem: job) - } - } - if viewModel.isLoadingMore { - HStack { - Spacer() - ProgressView() - Spacer() + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) } - .listRowSeparator(.hidden) } } .themedList() .listStyle(.plain) + .listSectionSpacing(.compact) .searchable( text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), diff --git a/Hutch/Views/Inbox/ThreadViewModel.swift b/Hutch/Views/Inbox/ThreadViewModel.swift index 1c3705b..850b83c 100644 --- a/Hutch/Views/Inbox/ThreadViewModel.swift +++ b/Hutch/Views/Inbox/ThreadViewModel.swift @@ -693,9 +693,6 @@ final class ThreadViewModel { } private static func isRecoverableNoRows(_ error: Error) -> Bool { - guard case let SRHTError.graphQLErrors(errors) = error else { - return false - } - return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") } + error.matchesGraphQLErrorClassification(.noRows) } } diff --git a/Hutch/Views/Repositories/HgRepositoryDetailView.swift b/Hutch/Views/Repositories/HgRepositoryDetailView.swift index ac7c04f..8362467 100644 --- a/Hutch/Views/Repositories/HgRepositoryDetailView.swift +++ b/Hutch/Views/Repositories/HgRepositoryDetailView.swift @@ -7,6 +7,7 @@ struct HgRepositoryDetailView: View { @Environment(AppState.self) private var appState @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL @Environment(\.colorScheme) private var colorScheme @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false @@ -99,6 +100,7 @@ struct HgRepositoryDetailView: View { async let log: () = vm.loadLog() _ = await (summary, browse, log) } + RecentActivityStore.recordRepository(repository, defaults: appState.accountDefaults) } } @@ -132,6 +134,42 @@ struct HgRepositoryDetailView: View { } } + Divider() + + if let repositoryURL = SRHTWebURL.repository(repository) { + Button { + openURL(repositoryURL) + } label: { + Label("Open in Browser", systemImage: "safari") + } + + Button { + appState.copyToPasteboard(repositoryURL.absoluteString, label: "repository URL") + } label: { + Label("Copy URL", systemImage: "doc.on.doc") + } + } + + if let httpsURL = SRHTWebURL.httpsCloneURL(repository) { + Button { + appState.copyToPasteboard(httpsURL, label: "HTTPS clone URL") + } label: { + Label("Copy HTTPS URL", systemImage: "doc.on.doc") + } + } + + Button { + appState.copyToPasteboard(SRHTWebURL.sshCloneURL(repository), label: "SSH clone URL") + } label: { + Label("Copy SSH URL", systemImage: "terminal") + } + + Button { + appState.copyToPasteboard(repository.rid, label: "repository RID") + } label: { + Label("Copy RID", systemImage: "number") + } + if canManageRepository { Divider() @@ -227,10 +265,12 @@ struct HgRepositoryDetailView: View { @ViewBuilder private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { VStack(alignment: .leading, spacing: 10) { - SummaryMetadataRow( - icon: "arrow.triangle.branch", - title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility) - ) + if let branchLabel = repositoryPrimaryBranchLabel(for: repository, hgTipBranch: viewModel.tip?.branch) { + SummaryMetadataRow( + icon: "arrow.triangle.branch", + title: branchLabel + ) + } if let readmePath = viewModel.readmePath { SummaryMetadataRow( @@ -244,6 +284,7 @@ struct HgRepositoryDetailView: View { private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { VStack(alignment: .leading, spacing: 12) { + SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(repository.service)) SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility)) SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing") SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true) diff --git a/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift index 5a0298c..d8303e1 100644 --- a/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift +++ b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift @@ -526,20 +526,9 @@ final class HgRepositoryDetailViewModel { } private func isEmptyRepositoryError(_ error: Error) -> Bool { - if let srhtError = error as? SRHTError, - case .graphQLErrors(let errors) = srhtError { - return errors.contains { - let message = $0.message.localizedLowercase - return message.contains("missing") - || message.contains("not found") - || message.contains("unknown revision") - || message.contains("unknown revision or path not in the working tree") - } - } - - let message = error.localizedDescription.localizedLowercase - return message.contains("missing") - || message.contains("not found") - || message.contains("unknown revision") + error.matchesGraphQLErrorClassification(.notFound) + || error.matchesGraphQLErrorClassification(.unknownRevision) + || error.matchesGraphQLErrorClassification(.noRows) + || error.containsGraphQLErrorMessage("missing") } } diff --git a/Hutch/Views/Repositories/HgRepositorySettingsView.swift b/Hutch/Views/Repositories/HgRepositorySettingsView.swift index 6a27465..a6aa728 100644 --- a/Hutch/Views/Repositories/HgRepositorySettingsView.swift +++ b/Hutch/Views/Repositories/HgRepositorySettingsView.swift @@ -9,7 +9,6 @@ struct HgRepositorySettingsView: View { @State private var viewModel: HgRepositorySettingsViewModel? @State private var showDeleteConfirmation = false @State private var pendingACLDeletion: HgACLEntry? - @State private var saveResultAlert: SaveResultAlert? var body: some View { NavigationStack { @@ -93,23 +92,26 @@ struct HgRepositorySettingsView: View { Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.") } } - .alert(item: $saveResultAlert) { alert in - Alert( - title: Text(alert.title), - message: Text(alert.message), - dismissButton: .default(Text("OK")) - ) - } } @ViewBuilder private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { - Section("Info") { - LabeledContent("Name") { - Text(repository.name) + Section("Current Configuration") { + LabeledContent("Repository") { + Text("\(repository.owner.canonicalName)/\(repository.name)") .font(.body.monospaced()) } + LabeledContent("Forge") { + Text(repositoryForgeLabel(repository.service)) + } + + LabeledContent("Visibility") { + Text(repositoryVisibilityLabel(viewModel.editedVisibility)) + } + } + + Section("Repository Details") { TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) .lineLimit(3...6) @@ -121,11 +123,7 @@ struct HgRepositorySettingsView: View { Button { Task { - let didSave = await viewModel.saveInfo() - saveResultAlert = SaveResultAlert( - title: didSave ? "Settings Updated" : "Couldn't Update Settings", - message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.") - ) + _ = await viewModel.saveInfo() } } label: { if viewModel.isSavingInfo { @@ -136,7 +134,7 @@ struct HgRepositorySettingsView: View { .frame(maxWidth: .infinity) } } - .disabled(viewModel.isSavingInfo) + .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty) } } @@ -202,16 +200,15 @@ struct HgRepositorySettingsView: View { @ViewBuilder private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { - Section("Features") { + Section("Sensitive Settings") { Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing) + Text("Changes stay pending until you save this section.") + .font(.caption) + .foregroundStyle(.secondary) Button { Task { - let didSave = await viewModel.saveInfo() - saveResultAlert = SaveResultAlert( - title: didSave ? "Settings Updated" : "Couldn't Update Settings", - message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.") - ) + _ = await viewModel.saveInfo() } } label: { if viewModel.isSavingInfo { @@ -222,7 +219,7 @@ struct HgRepositorySettingsView: View { .frame(maxWidth: .infinity) } } - .disabled(viewModel.isSavingInfo) + .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty) } } @@ -261,13 +258,8 @@ struct HgRepositorySettingsView: View { } } .disabled(viewModel.isDeleting) + } header: { + Text("Danger Zone") } } - - private struct SaveResultAlert: Identifiable { - let title: String - let message: String - - var id: String { "\(title)-\(message)" } - } } diff --git a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift index a1a11ae..7b8d7f7 100644 --- a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift +++ b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift @@ -64,11 +64,14 @@ final class HgRepositorySettingsViewModel { let repositoryRid: String let repositoryName: String private let client: SRHTClient + private var initialDescription: String + private var initialVisibility: Visibility var editedDescription: String var editedVisibility: Visibility var editedNonPublishing: Bool var isSavingInfo = false + private(set) var loadedNonPublishing = false private(set) var acls: [HgACLEntry] = [] private(set) var isLoadingACLs = false @@ -82,12 +85,25 @@ final class HgRepositorySettingsViewModel { var didDelete = false var error: String? + var normalizedEditedDescription: String { + editedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var isInfoDirty: Bool { + normalizedEditedDescription != initialDescription || + editedVisibility != initialVisibility || + editedNonPublishing != loadedNonPublishing + } + init(repository: RepositorySummary, client: SRHTClient) { self.repositoryId = repository.id self.repositoryRid = repository.rid self.repositoryName = repository.name self.client = client - self.editedDescription = repository.description ?? "" + let description = repository.description ?? "" + self.initialDescription = description + self.initialVisibility = repository.visibility + self.editedDescription = description self.editedVisibility = repository.visibility self.editedNonPublishing = false } @@ -159,9 +175,14 @@ final class HgRepositorySettingsViewModel { ) if let repository = result.repository { - editedDescription = repository.description ?? "" + let description = repository.description ?? "" + initialDescription = description + initialVisibility = repository.visibility + editedDescription = description editedVisibility = repository.visibility - editedNonPublishing = repository.nonPublishing ?? false + let nonPublishing = repository.nonPublishing ?? false + editedNonPublishing = nonPublishing + loadedNonPublishing = nonPublishing } } catch { self.error = error.userFacingMessage @@ -175,7 +196,7 @@ final class HgRepositorySettingsViewModel { do { let input: [String: any Sendable] = [ - "description": editedDescription, + "description": normalizedEditedDescription, "visibility": editedVisibility.rawValue, "nonPublishing": editedNonPublishing ] @@ -185,6 +206,9 @@ final class HgRepositorySettingsViewModel { variables: ["id": repositoryId, "input": input], responseType: HgUpdateRepositoryResponse.self ) + initialDescription = normalizedEditedDescription + initialVisibility = editedVisibility + loadedNonPublishing = editedNonPublishing return true } catch { self.error = error.userFacingMessage @@ -237,9 +261,8 @@ final class HgRepositorySettingsViewModel { } newACLEntity = "" } catch { - let message = error.localizedDescription - if message.localizedCaseInsensitiveContains("No such repository or user found") { - self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first." + if error.matchesGraphQLErrorClassification(.serviceNotProvisioned) { + self.error = error.userFacingMessage } else { self.error = error.userFacingMessage } diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift index f5c0c9f..21d7a74 100644 --- a/Hutch/Views/Repositories/ReadmeView.swift +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -54,10 +54,12 @@ struct ReadmeView: View { @ViewBuilder private var metadataSection: some View { VStack(alignment: .leading, spacing: 10) { - SummaryMetadataRow( - icon: "arrow.triangle.branch", - title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility) - ) + if let branchLabel = repositoryPrimaryBranchLabel(for: viewModel.repository) { + SummaryMetadataRow( + icon: "arrow.triangle.branch", + title: branchLabel + ) + } if let readmePath = viewModel.readmePath { SummaryMetadataRow( @@ -71,6 +73,7 @@ struct ReadmeView: View { private var repositoryDetailsSection: some View { DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { VStack(alignment: .leading, spacing: 12) { + SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(viewModel.repository.service)) SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility)) SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true) SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true) diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift index 72c4d1f..ebd07d2 100644 --- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift @@ -372,9 +372,7 @@ final class RepositoryDetailViewModel { } private func isMissingGitReferenceError(_ error: Error) -> Bool { - guard let srhtError = error as? SRHTError else { return false } - guard case .graphQLErrors(let errors) = srhtError else { return false } - return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") } + error.matchesGraphQLErrorClassification(.missingReference) } // MARK: - Artifacts diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift index 0da6cbc..afbd740 100644 --- a/Hutch/Views/Repositories/RepositoryRowView.swift +++ b/Hutch/Views/Repositories/RepositoryRowView.swift @@ -15,14 +15,7 @@ struct RepositoryRowView: View { Spacer() - if repository.service == .hg { - Text("HG") - .font(.caption2.weight(.medium)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.cyan.opacity(0.15), in: Capsule()) - .foregroundStyle(.cyan) - } + RepositoryForgeBadge(service: repository.service) if buildStatus != .none { RepositoryBuildStatusIndicator(status: buildStatus) diff --git a/Hutch/Views/Repositories/RepositorySummarySupport.swift b/Hutch/Views/Repositories/RepositorySummarySupport.swift index 7861b40..741ff39 100644 --- a/Hutch/Views/Repositories/RepositorySummarySupport.swift +++ b/Hutch/Views/Repositories/RepositorySummarySupport.swift @@ -46,6 +46,28 @@ func repositoryVisibilityLabel(_ visibility: Visibility) -> String { } } +func repositoryForgeLabel(_ service: SRHTService) -> String { + switch service { + case .git: + return "GIT" + case .hg: + return "HG" + default: + return service.rawValue.uppercased() + } +} + +func repositoryPrimaryBranchLabel(for repository: RepositorySummary, hgTipBranch: String? = nil) -> String? { + switch repository.service { + case .git: + return repository.defaultBranchName + case .hg: + return hgTipBranch ?? repository.defaultBranchName ?? "tip" + default: + return repository.defaultBranchName + } +} + struct SummaryMetadataRow: View { let icon: String let title: String @@ -85,3 +107,27 @@ struct SummaryDetailRow: View { } } } + +struct RepositoryForgeBadge: View { + let service: SRHTService + + var body: some View { + Text(repositoryForgeLabel(service)) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.15), in: Capsule()) + .foregroundStyle(color) + } + + private var color: Color { + switch service { + case .git: + .indigo + case .hg: + .cyan + default: + .secondary + } + } +} diff --git a/HutchTests/SRHTClientTests.swift b/HutchTests/SRHTClientTests.swift index 2ce2529..12d6d0f 100644 --- a/HutchTests/SRHTClientTests.swift +++ b/HutchTests/SRHTClientTests.swift @@ -24,4 +24,33 @@ struct SRHTClientTests { Issue.record("Expected SRHTError.invalidAuthenticatedURL, got \(error).") } } + + @Test + func graphQLErrorUserFacingMessagePreservesValidationDetails() { + let error = SRHTError.graphQLErrors([ + GraphQLError(message: "A tracker named bugs already exists", locations: nil) + ]) + + #expect(error.userFacingMessage == "A tracker named bugs already exists") + } + + @Test + func graphQLErrorUserFacingMessageClassifiesNotFoundResponses() { + let error = SRHTError.graphQLErrors([ + GraphQLError(message: "reference not found", locations: nil) + ]) + + #expect(error.userFacingMessage == "That content is no longer available.") + #expect(error.matchesGraphQLErrorClassification(.missingReference)) + } + + @Test + func graphQLErrorUserFacingMessageClassifiesServiceProvisioningFailures() { + let error = SRHTError.graphQLErrors([ + GraphQLError(message: "No such repository or user found", locations: nil) + ]) + + #expect(error.userFacingMessage == "That account needs to activate this SourceHut service before this action can succeed.") + #expect(error.matchesGraphQLErrorClassification(.serviceNotProvisioned)) + } } |
