krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

5e63048ab6a741ae947f7fe42efcb5206a3f3963

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T19:20:27Z

fix: mercurial parity and graphql handling

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

Implements: https://todo.sr.ht/~ccleberg/hutch/58
Implements: https://todo.sr.ht/~ccleberg/hutch/59
 Hutch.xcodeproj/project.pbxproj                    | 16 ++--
 Hutch/Networking/SRHTClient.swift                  | 33 ++++----
 Hutch/Networking/SRHTError.swift                   | 88 ++++++++++++++++++++--
 Hutch/Networking/SystemStatusRepository.swift      | 51 +------------
 Hutch/Views/Builds/BuildListView.swift             | 87 +++++++++++----------
 Hutch/Views/Inbox/ThreadViewModel.swift            |  5 +-
 .../Repositories/HgRepositoryDetailView.swift      | 49 +++++++++++-
 .../Repositories/HgRepositoryDetailViewModel.swift | 19 +----
 .../Repositories/HgRepositorySettingsView.swift    | 54 ++++++-------
 .../HgRepositorySettingsViewModel.swift            | 37 +++++++--
 Hutch/Views/Repositories/ReadmeView.swift          | 11 ++-
 .../Repositories/RepositoryDetailViewModel.swift   |  4 +-
 Hutch/Views/Repositories/RepositoryRowView.swift   |  9 +--
 .../Repositories/RepositorySummarySupport.swift    | 46 +++++++++++
 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))
+    }
 }