krz/hutch

an ios client for sourcehut

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

d4757fd9f3aba663df967ae7cadb7f74c3d7acb4

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T00:07:57Z

fix: polish build retry and cancel actions

fixes: https://todo.sr.ht/~ccleberg/hutch/19
 Hutch.xcodeproj/project.pbxproj               | 16 ++++-----
 Hutch/Views/Builds/BuildDetailView.swift      | 14 ++++----
 Hutch/Views/Builds/BuildDetailViewModel.swift | 50 ++++++++++++++++++++++-----
 3 files changed, 58 insertions(+), 22 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index b1ee360..51c63c4 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 = 37;
+				CURRENT_PROJECT_VERSION = 38;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.16.1;
+				MARKETING_VERSION = 2.16.2;
 				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 = 37;
+				CURRENT_PROJECT_VERSION = 38;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.16.1;
+				MARKETING_VERSION = 2.16.2;
 				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 = 37;
+				CURRENT_PROJECT_VERSION = 38;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.16.1;
+				MARKETING_VERSION = 2.16.2;
 				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 = 37;
+				CURRENT_PROJECT_VERSION = 38;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.16.1;
+				MARKETING_VERSION = 2.16.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
index 33590e2..5070536 100644
--- a/Hutch/Views/Builds/BuildDetailView.swift
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -262,6 +262,10 @@ struct BuildDetailView: View {
                 get: { viewModel.error },
                 set: { viewModel.error = $0 }
             ))
+            .srhtErrorBanner(error: Binding(
+                get: { viewModel.actionError },
+                set: { _ in viewModel.dismissActionError() }
+            ))
         }
     }
 
@@ -287,7 +291,6 @@ private struct EditResubmitBuildSheet: View {
     let onSubmitted: (Int) -> Void
 
     @Environment(\.dismiss) private var dismiss
-    @Bindable var viewModelBindable: BuildDetailViewModel
     @State private var manifest: String
     @State private var tagsText: String
     @State private var note: String
@@ -297,7 +300,6 @@ private struct EditResubmitBuildSheet: View {
 
     init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
         self.viewModel = viewModel
-        self._viewModelBindable = Bindable(viewModel)
         self.job = job
         self.onSubmitted = onSubmitted
         _manifest = State(initialValue: job.manifest ?? "")
@@ -337,10 +339,10 @@ private struct EditResubmitBuildSheet: View {
                         .foregroundStyle(.secondary)
                 }
 
-                if let error = viewModel.error {
+                if let actionError = viewModel.actionError {
                     Section {
                         Label {
-                            Text(error)
+                            Text(actionError)
                         } icon: {
                             Image(systemName: "exclamationmark.triangle.fill")
                                 .foregroundStyle(.red)
@@ -352,12 +354,12 @@ private struct EditResubmitBuildSheet: View {
             .navigationTitle("Edit & Resubmit")
             .navigationBarTitleDisplayMode(.inline)
             .onDisappear {
-                viewModelBindable.error = nil
+                viewModel.dismissActionError()
             }
             .toolbar {
                 ToolbarItem(placement: .cancellationAction) {
                     Button("Cancel") {
-                        viewModelBindable.error = nil
+                        viewModel.dismissActionError()
                         dismiss()
                     }
                 }
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift
index 88b9e42..803ab33 100644
--- a/Hutch/Views/Builds/BuildDetailViewModel.swift
+++ b/Hutch/Views/Builds/BuildDetailViewModel.swift
@@ -45,12 +45,32 @@ final class BuildDetailViewModel {
     private(set) var isRebuilding = false
     private(set) var isSubmittingEditedBuild = false
     var error: String?
+    /// Transient error shown for action failures (cancel, rebuild, submit).
+    /// Separate from `error` so auto-refresh doesn't immediately clear it.
+    private(set) var actionError: String?
+    private var actionErrorDismissTask: Task<Void, Never>?
 
     init(jobId: Int, client: SRHTClient) {
         self.jobId = jobId
         self.client = client
     }
 
+    func dismissActionError() {
+        actionError = nil
+        actionErrorDismissTask?.cancel()
+        actionErrorDismissTask = nil
+    }
+
+    private func setActionError(_ message: String) {
+        actionError = message
+        actionErrorDismissTask?.cancel()
+        actionErrorDismissTask = Task {
+            try? await Task.sleep(for: .seconds(5))
+            guard !Task.isCancelled else { return }
+            actionError = nil
+        }
+    }
+
     // MARK: - Queries
 
     private static let detailQuery = """
@@ -208,8 +228,18 @@ final class BuildDetailViewModel {
 
     func cancelJob() async {
         guard let job, job.status.isCancellable, !isCancelling else { return }
+        let originalJob = job
         isCancelling = true
-        error = nil
+
+        // Optimistic update: show cancelled status immediately.
+        self.job = JobDetail(
+            id: job.id, created: job.created, updated: job.updated,
+            status: .cancelled, note: job.note, tags: job.tags,
+            visibility: job.visibility, image: job.image,
+            manifest: job.manifest, tasks: job.tasks,
+            log: job.log, owner: job.owner
+        )
+        stopAutoRefresh()
 
         do {
             _ = try await client.execute(
@@ -218,10 +248,14 @@ final class BuildDetailViewModel {
                 variables: ["id": jobId],
                 responseType: CancelResponse.self
             )
-            // Reload job to get updated status.
             await loadJob()
         } catch {
-            self.error = error.userFacingMessage
+            // Revert optimistic update on failure.
+            self.job = originalJob
+            if !originalJob.status.isTerminal {
+                startAutoRefresh()
+            }
+            setActionError("Couldn't cancel build. \(error.userFacingMessage)")
         }
 
         isCancelling = false
@@ -233,7 +267,7 @@ final class BuildDetailViewModel {
         }
 
         isRebuilding = true
-        error = nil
+        dismissActionError()
         defer { isRebuilding = false }
 
         var variables: [String: any Sendable] = [
@@ -258,7 +292,7 @@ final class BuildDetailViewModel {
             )
             return result.submit.id
         } catch {
-            self.error = error.userFacingMessage
+            setActionError("Couldn't rebuild. \(error.userFacingMessage)")
             return nil
         }
     }
@@ -275,12 +309,12 @@ final class BuildDetailViewModel {
 
         let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !trimmedManifest.isEmpty else {
-            error = "Paste a build manifest."
+            setActionError("Paste a build manifest.")
             return nil
         }
 
         isSubmittingEditedBuild = true
-        error = nil
+        dismissActionError()
         defer { isSubmittingEditedBuild = false }
 
         var variables: [String: any Sendable] = [
@@ -306,7 +340,7 @@ final class BuildDetailViewModel {
             )
             return result.submit.id
         } catch {
-            self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
+            setActionError("Couldn’t submit the build. \(error.userFacingMessage)")
             return nil
         }
     }