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 @@
515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517 CODE_SIGN_STYLE = Automatic; 517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 37; 518 CURRENT_PROJECT_VERSION = 38;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 ENABLE_PREVIEWS = YES; 520 ENABLE_PREVIEWS = YES;
521 GENERATE_INFOPLIST_FILE = YES; 521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532 "$(inherited)", 532 "$(inherited)",
533 "@executable_path/Frameworks", 533 "@executable_path/Frameworks",
534 ); 534 );
535 MARKETING_VERSION = 2.16.1; 535 MARKETING_VERSION = 2.16.2;
536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537 PRODUCT_NAME = "$(TARGET_NAME)"; 537 PRODUCT_NAME = "$(TARGET_NAME)";
538 STRING_CATALOG_GENERATE_SYMBOLS = YES; 538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554 CODE_SIGN_STYLE = Automatic; 554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 37; 555 CURRENT_PROJECT_VERSION = 38;
556 DEVELOPMENT_TEAM = ZCNAX3VL9D; 556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557 ENABLE_PREVIEWS = YES; 557 ENABLE_PREVIEWS = YES;
558 GENERATE_INFOPLIST_FILE = YES; 558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569 "$(inherited)", 569 "$(inherited)",
570 "@executable_path/Frameworks", 570 "@executable_path/Frameworks",
571 ); 571 );
572 MARKETING_VERSION = 2.16.1; 572 MARKETING_VERSION = 2.16.2;
573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574 PRODUCT_NAME = "$(TARGET_NAME)"; 574 PRODUCT_NAME = "$(TARGET_NAME)";
575 STRING_CATALOG_GENERATE_SYMBOLS = YES; 575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 37; 635 CURRENT_PROJECT_VERSION = 38;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642 "@executable_path/Frameworks", 642 "@executable_path/Frameworks",
643 "@executable_path/../../Frameworks", 643 "@executable_path/../../Frameworks",
644 ); 644 );
645 MARKETING_VERSION = 2.16.1; 645 MARKETING_VERSION = 2.16.2;
646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647 PRODUCT_NAME = "$(TARGET_NAME)"; 647 PRODUCT_NAME = "$(TARGET_NAME)";
648 SKIP_INSTALL = YES; 648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 37; 664 CURRENT_PROJECT_VERSION = 38;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671 "@executable_path/Frameworks", 671 "@executable_path/Frameworks",
672 "@executable_path/../../Frameworks", 672 "@executable_path/../../Frameworks",
673 ); 673 );
674 MARKETING_VERSION = 2.16.1; 674 MARKETING_VERSION = 2.16.2;
675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676 PRODUCT_NAME = "$(TARGET_NAME)"; 676 PRODUCT_NAME = "$(TARGET_NAME)";
677 SKIP_INSTALL = YES; 677 SKIP_INSTALL = YES;
Hutch/Views/Builds/BuildDetailView.swift +8 −6
@@ -262,6 +262,10 @@ struct BuildDetailView: View {
262 get: { viewModel.error }, 262 get: { viewModel.error },
263 set: { viewModel.error = $0 } 263 set: { viewModel.error = $0 }
264 )) 264 ))
265 .srhtErrorBanner(error: Binding(
266 get: { viewModel.actionError },
267 set: { _ in viewModel.dismissActionError() }
268 ))
265 } 269 }
266 } 270 }
267 271
@@ -287,7 +291,6 @@ private struct EditResubmitBuildSheet: View {
287 let onSubmitted: (Int) -> Void 291 let onSubmitted: (Int) -> Void
288 292
289 @Environment(\.dismiss) private var dismiss 293 @Environment(\.dismiss) private var dismiss
290 @Bindable var viewModelBindable: BuildDetailViewModel
291 @State private var manifest: String 294 @State private var manifest: String
292 @State private var tagsText: String 295 @State private var tagsText: String
293 @State private var note: String 296 @State private var note: String
@@ -297,7 +300,6 @@ private struct EditResubmitBuildSheet: View {
297 300
298 init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) { 301 init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
299 self.viewModel = viewModel 302 self.viewModel = viewModel
300 self._viewModelBindable = Bindable(viewModel)
301 self.job = job 303 self.job = job
302 self.onSubmitted = onSubmitted 304 self.onSubmitted = onSubmitted
303 _manifest = State(initialValue: job.manifest ?? "") 305 _manifest = State(initialValue: job.manifest ?? "")
@@ -337,10 +339,10 @@ private struct EditResubmitBuildSheet: View {
337 .foregroundStyle(.secondary) 339 .foregroundStyle(.secondary)
338 } 340 }
339 341
340 if let error = viewModel.error { 342 if let actionError = viewModel.actionError {
341 Section { 343 Section {
342 Label { 344 Label {
343 Text(error) 345 Text(actionError)
344 } icon: { 346 } icon: {
345 Image(systemName: "exclamationmark.triangle.fill") 347 Image(systemName: "exclamationmark.triangle.fill")
346 .foregroundStyle(.red) 348 .foregroundStyle(.red)
@@ -352,12 +354,12 @@ private struct EditResubmitBuildSheet: View {
352 .navigationTitle("Edit & Resubmit") 354 .navigationTitle("Edit & Resubmit")
353 .navigationBarTitleDisplayMode(.inline) 355 .navigationBarTitleDisplayMode(.inline)
354 .onDisappear { 356 .onDisappear {
355 viewModelBindable.error = nil 357 viewModel.dismissActionError()
356 } 358 }
357 .toolbar { 359 .toolbar {
358 ToolbarItem(placement: .cancellationAction) { 360 ToolbarItem(placement: .cancellationAction) {
359 Button("Cancel") { 361 Button("Cancel") {
360 viewModelBindable.error = nil 362 viewModel.dismissActionError()
361 dismiss() 363 dismiss()
362 } 364 }
363 } 365 }
Hutch/Views/Builds/BuildDetailViewModel.swift +42 −8
@@ -45,12 +45,32 @@ final class BuildDetailViewModel {
45 private(set) var isRebuilding = false 45 private(set) var isRebuilding = false
46 private(set) var isSubmittingEditedBuild = false 46 private(set) var isSubmittingEditedBuild = false
47 var error: String? 47 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>?
48 52
49 init(jobId: Int, client: SRHTClient) { 53 init(jobId: Int, client: SRHTClient) {
50 self.jobId = jobId 54 self.jobId = jobId
51 self.client = client 55 self.client = client
52 } 56 }
53 57
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
54 // MARK: - Queries 74 // MARK: - Queries
55 75
56 private static let detailQuery = """ 76 private static let detailQuery = """
@@ -208,8 +228,18 @@ final class BuildDetailViewModel {
208 228
209 func cancelJob() async { 229 func cancelJob() async {
210 guard let job, job.status.isCancellable, !isCancelling else { return } 230 guard let job, job.status.isCancellable, !isCancelling else { return }
231 let originalJob = job
211 isCancelling = true 232 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()
213 243
214 do { 244 do {
215 _ = try await client.execute( 245 _ = try await client.execute(
@@ -218,10 +248,14 @@ final class BuildDetailViewModel {
218 variables: ["id": jobId], 248 variables: ["id": jobId],
219 responseType: CancelResponse.self 249 responseType: CancelResponse.self
220 ) 250 )
221 // Reload job to get updated status.
222 await loadJob() 251 await loadJob()
223 } catch { 252 } 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)")
225 } 259 }
226 260
227 isCancelling = false 261 isCancelling = false
@@ -233,7 +267,7 @@ final class BuildDetailViewModel {
233 } 267 }
234 268
235 isRebuilding = true 269 isRebuilding = true
236 error = nil 270 dismissActionError()
237 defer { isRebuilding = false } 271 defer { isRebuilding = false }
238 272
239 var variables: [String: any Sendable] = [ 273 var variables: [String: any Sendable] = [
@@ -258,7 +292,7 @@ final class BuildDetailViewModel {
258 ) 292 )
259 return result.submit.id 293 return result.submit.id
260 } catch { 294 } catch {
261 self.error = error.userFacingMessage 295 setActionError("Couldn't rebuild. \(error.userFacingMessage)")
262 return nil 296 return nil
263 } 297 }
264 } 298 }
@@ -275,12 +309,12 @@ final class BuildDetailViewModel {
275 309
276 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines) 310 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
277 guard !trimmedManifest.isEmpty else { 311 guard !trimmedManifest.isEmpty else {
278 error = "Paste a build manifest." 312 setActionError("Paste a build manifest.")
279 return nil 313 return nil
280 } 314 }
281 315
282 isSubmittingEditedBuild = true 316 isSubmittingEditedBuild = true
283 error = nil 317 dismissActionError()
284 defer { isSubmittingEditedBuild = false } 318 defer { isSubmittingEditedBuild = false }
285 319
286 var variables: [String: any Sendable] = [ 320 var variables: [String: any Sendable] = [
@@ -306,7 +340,7 @@ final class BuildDetailViewModel {
306 ) 340 )
307 return result.submit.id 341 return result.submit.id
308 } catch { 342 } catch {
309 self.error = "Couldn’t submit the build. \(error.userFacingMessage)" 343 setActionError("Couldn’t submit the build. \(error.userFacingMessage)")
310 return nil 344 return nil
311 } 345 }
312 } 346 }