krz/hutch

an ios client for sourcehut

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

090c829a5f800266da7f4ff0e59fb389f0dbfdd8

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-19T01:03:39Z

show tracker creation errors inside the creation sheet
 Hutch/Views/Settings/SettingsViewModel.swift   |  4 +--
 Hutch/Views/Tickets/TrackerListView.swift      | 27 +++++++++++++++++-
 Hutch/Views/Tickets/TrackerListViewModel.swift | 19 ++++++++++++-
 HutchTests/SettingsViewModelTests.swift        | 39 ++++++++++++++++++++++++++
 HutchTests/TrackerListViewModelTests.swift     | 16 +++++++++++
 5 files changed, 101 insertions(+), 4 deletions(-)

diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift
index 204afbf..1cc5426 100644
--- a/Hutch/Views/Settings/SettingsViewModel.swift
+++ b/Hutch/Views/Settings/SettingsViewModel.swift
@@ -24,7 +24,7 @@ private struct CreateSSHKeyResponse: Decodable, Sendable {
 }
 
 private struct DeleteSSHKeyResponse: Decodable, Sendable {
-    let deleteSSHKey: DeleteResult
+    let deleteSSHKey: DeleteResult?
 }
 
 private struct CreatePGPKeyResponse: Decodable, Sendable {
@@ -32,7 +32,7 @@ private struct CreatePGPKeyResponse: Decodable, Sendable {
 }
 
 private struct DeletePGPKeyResponse: Decodable, Sendable {
-    let deletePGPKey: DeleteResult
+    let deletePGPKey: DeleteResult?
 }
 
 private struct DeleteResult: Decodable, Sendable {
diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift
index d16246b..ee253ec 100644
--- a/Hutch/Views/Tickets/TrackerListView.swift
+++ b/Hutch/Views/Tickets/TrackerListView.swift
@@ -124,10 +124,17 @@ private struct CreateTrackerSheet: View {
     let onCreated: (TrackerSummary) -> Void
 
     @Environment(\.dismiss) private var dismiss
+    @Bindable var viewModelBindable: TrackerListViewModel
     @State private var name = ""
     @State private var description = ""
     @State private var visibility: Visibility = .public
 
+    init(viewModel: TrackerListViewModel, onCreated: @escaping (TrackerSummary) -> Void) {
+        self.viewModel = viewModel
+        self._viewModelBindable = Bindable(viewModel)
+        self.onCreated = onCreated
+    }
+
     var body: some View {
         NavigationStack {
             Form {
@@ -143,12 +150,30 @@ private struct CreateTrackerSheet: View {
                         Text("Private").tag(Visibility.private)
                     }
                 }
+
+                if let error = viewModel.error {
+                    Section {
+                        Label {
+                            Text(error)
+                        } icon: {
+                            Image(systemName: "exclamationmark.triangle.fill")
+                                .foregroundStyle(.red)
+                        }
+                        .foregroundStyle(.red)
+                    }
+                }
             }
             .navigationTitle("New Tracker")
             .navigationBarTitleDisplayMode(.inline)
+            .onDisappear {
+                viewModelBindable.error = nil
+            }
             .toolbar {
                 ToolbarItem(placement: .cancellationAction) {
-                    Button("Cancel") { dismiss() }
+                    Button("Cancel") {
+                        viewModelBindable.error = nil
+                        dismiss()
+                    }
                 }
                 ToolbarItem(placement: .confirmationAction) {
                     Button {
diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift
index 9704071..978a610 100644
--- a/Hutch/Views/Tickets/TrackerListViewModel.swift
+++ b/Hutch/Views/Tickets/TrackerListViewModel.swift
@@ -139,7 +139,7 @@ final class TrackerListViewModel {
             trackers.insert(tracker, at: 0)
             return tracker
         } catch {
-            self.error = "Couldn’t create the tracker. \(error.localizedDescription)"
+            self.error = trackerCreationErrorMessage(for: error)
             return nil
         }
     }
@@ -163,4 +163,21 @@ final class TrackerListViewModel {
     private struct CreateTrackerResponse: Decodable, Sendable {
         let createTracker: TrackerSummary
     }
+
+    private func trackerCreationErrorMessage(for error: Error) -> String {
+        let message: String
+
+        if let srhtError = error as? SRHTError {
+            switch srhtError {
+            case .graphQLErrors(let errors):
+                message = errors.map(\.message).joined(separator: "\n")
+            default:
+                message = srhtError.localizedDescription
+            }
+        } else {
+            message = error.localizedDescription
+        }
+
+        return "Couldn’t create the tracker. \(message)"
+    }
 }
diff --git a/HutchTests/SettingsViewModelTests.swift b/HutchTests/SettingsViewModelTests.swift
new file mode 100644
index 0000000..fc18162
--- /dev/null
+++ b/HutchTests/SettingsViewModelTests.swift
@@ -0,0 +1,39 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+private struct DeletePGPKeyEnvelope: Decodable {
+    let deletePGPKey: DeleteResultPayload?
+}
+
+private struct DeleteResultPayload: Decodable {
+    let id: Int?
+}
+
+struct SettingsViewModelTests {
+
+    @Test
+    @MainActor
+    func deletePGPKeyResponseDecodesNullPayloadWithGraphQLErrors() throws {
+        let json = """
+        {
+            "errors": [
+                {
+                    "message": "PGP key ID 13629 is set as the user's preferred PGP key - it must be unset before removing the key"
+                }
+            ],
+            "data": {
+                "deletePGPKey": null
+            }
+        }
+        """
+
+        let decoded = try JSONDecoder().decode(
+            GraphQLResponse<DeletePGPKeyEnvelope>.self,
+            from: Data(json.utf8)
+        )
+
+        #expect(decoded.data?.deletePGPKey == nil)
+        #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true)
+    }
+}
diff --git a/HutchTests/TrackerListViewModelTests.swift b/HutchTests/TrackerListViewModelTests.swift
new file mode 100644
index 0000000..1caedea
--- /dev/null
+++ b/HutchTests/TrackerListViewModelTests.swift
@@ -0,0 +1,16 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct TrackerListViewModelTests {
+
+    @Test
+    @MainActor
+    func graphQLErrorDescriptionIsPreservedForTrackerCreationFailures() {
+        let error = SRHTError.graphQLErrors([
+            GraphQLError(message: "A tracker named bugs already exists", locations: nil)
+        ])
+
+        #expect(error.localizedDescription == "GraphQL error: A tracker named bugs already exists")
+    }
+}