Commit 2a56dd7f00
Unsigned
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 | } |