Commit 2a56dd7f00

2a56dd7f007065d31d3daf4ce40df37fed0ae7d3

parent: 301b072f3b

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 00:07 UTC

fix: polish build retry and cancel actions

fixes: https://todo.sr.ht/~ccleberg/hutch/19

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 37;
518 CURRENT_PROJECT_VERSION = 38;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 2.16.1;
535 MARKETING_VERSION = 2.16.2;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 37;
555 CURRENT_PROJECT_VERSION = 38;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 2.16.1;
572 MARKETING_VERSION = 2.16.2;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 37;
635 CURRENT_PROJECT_VERSION = 38;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 2.16.1;
645 MARKETING_VERSION = 2.16.2;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 37;
664 CURRENT_PROJECT_VERSION = 38;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 2.16.1;
674 MARKETING_VERSION = 2.16.2;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/Views/Builds/BuildDetailView.swift +8 −6
@@ -262,6 +262,10 @@ struct BuildDetailView: View {
262262 get: { viewModel.error },
263263 set: { viewModel.error = $0 }
264264 ))
265 .srhtErrorBanner(error: Binding(
266 get: { viewModel.actionError },
267 set: { _ in viewModel.dismissActionError() }
268 ))
265269 }
266270 }
267271
@@ -287,7 +291,6 @@ private struct EditResubmitBuildSheet: View {
287291 let onSubmitted: (Int) -> Void
288292
289293 @Environment(\.dismiss) private var dismiss
290 @Bindable var viewModelBindable: BuildDetailViewModel
291294 @State private var manifest: String
292295 @State private var tagsText: String
293296 @State private var note: String
@@ -297,7 +300,6 @@ private struct EditResubmitBuildSheet: View {
297300
298301 init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
299302 self.viewModel = viewModel
300 self._viewModelBindable = Bindable(viewModel)
301303 self.job = job
302304 self.onSubmitted = onSubmitted
303305 _manifest = State(initialValue: job.manifest ?? "")
@@ -337,10 +339,10 @@ private struct EditResubmitBuildSheet: View {
337339 .foregroundStyle(.secondary)
338340 }
339341
340 if let error = viewModel.error {
342 if let actionError = viewModel.actionError {
341343 Section {
342344 Label {
343 Text(error)
345 Text(actionError)
344346 } icon: {
345347 Image(systemName: "exclamationmark.triangle.fill")
346348 .foregroundStyle(.red)
@@ -352,12 +354,12 @@ private struct EditResubmitBuildSheet: View {
352354 .navigationTitle("Edit & Resubmit")
353355 .navigationBarTitleDisplayMode(.inline)
354356 .onDisappear {
355 viewModelBindable.error = nil
357 viewModel.dismissActionError()
356358 }
357359 .toolbar {
358360 ToolbarItem(placement: .cancellationAction) {
359361 Button("Cancel") {
360 viewModelBindable.error = nil
362 viewModel.dismissActionError()
361363 dismiss()
362364 }
363365 }
Hutch/Views/Builds/BuildDetailViewModel.swift +42 −8
@@ -45,12 +45,32 @@ final class BuildDetailViewModel {
4545 private(set) var isRebuilding = false
4646 private(set) var isSubmittingEditedBuild = false
4747 var error: String?
48 /// Transient error shown for action failures (cancel, rebuild, submit).
49 /// Separate from `error` so auto-refresh doesn't immediately clear it.
50 private(set) var actionError: String?
51 private var actionErrorDismissTask: Task<Void, Never>?
4852
4953 init(jobId: Int, client: SRHTClient) {
5054 self.jobId = jobId
5155 self.client = client
5256 }
5357
58 func dismissActionError() {
59 actionError = nil
60 actionErrorDismissTask?.cancel()
61 actionErrorDismissTask = nil
62 }
63
64 private func setActionError(_ message: String) {
65 actionError = message
66 actionErrorDismissTask?.cancel()
67 actionErrorDismissTask = Task {
68 try? await Task.sleep(for: .seconds(5))
69 guard !Task.isCancelled else { return }
70 actionError = nil
71 }
72 }
73
5474 // MARK: - Queries
5575
5676 private static let detailQuery = """
@@ -208,8 +228,18 @@ final class BuildDetailViewModel {
208228
209229 func cancelJob() async {
210230 guard let job, job.status.isCancellable, !isCancelling else { return }
231 let originalJob = job
211232 isCancelling = true
212 error = nil
233
234 // Optimistic update: show cancelled status immediately.
235 self.job = JobDetail(
236 id: job.id, created: job.created, updated: job.updated,
237 status: .cancelled, note: job.note, tags: job.tags,
238 visibility: job.visibility, image: job.image,
239 manifest: job.manifest, tasks: job.tasks,
240 log: job.log, owner: job.owner
241 )
242 stopAutoRefresh()
213243
214244 do {
215245 _ = try await client.execute(
@@ -218,10 +248,14 @@ final class BuildDetailViewModel {
218248 variables: ["id": jobId],
219249 responseType: CancelResponse.self
220250 )
221 // Reload job to get updated status.
222251 await loadJob()
223252 } catch {
224 self.error = error.userFacingMessage
253 // Revert optimistic update on failure.
254 self.job = originalJob
255 if !originalJob.status.isTerminal {
256 startAutoRefresh()
257 }
258 setActionError("Couldn't cancel build. \(error.userFacingMessage)")
225259 }
226260
227261 isCancelling = false
@@ -233,7 +267,7 @@ final class BuildDetailViewModel {
233267 }
234268
235269 isRebuilding = true
236 error = nil
270 dismissActionError()
237271 defer { isRebuilding = false }
238272
239273 var variables: [String: any Sendable] = [
@@ -258,7 +292,7 @@ final class BuildDetailViewModel {
258292 )
259293 return result.submit.id
260294 } catch {
261 self.error = error.userFacingMessage
295 setActionError("Couldn't rebuild. \(error.userFacingMessage)")
262296 return nil
263297 }
264298 }
@@ -275,12 +309,12 @@ final class BuildDetailViewModel {
275309
276310 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
277311 guard !trimmedManifest.isEmpty else {
278 error = "Paste a build manifest."
312 setActionError("Paste a build manifest.")
279313 return nil
280314 }
281315
282316 isSubmittingEditedBuild = true
283 error = nil
317 dismissActionError()
284318 defer { isSubmittingEditedBuild = false }
285319
286320 var variables: [String: any Sendable] = [
@@ -306,7 +340,7 @@ final class BuildDetailViewModel {
306340 )
307341 return result.submit.id
308342 } catch {
309 self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
343 setActionError("Couldn’t submit the build. \(error.userFacingMessage)")
310344 return nil
311345 }
312346 }