diff options
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildDetailView.swift | 14 | ||||
| -rw-r--r-- | 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 } } |
