diff options
| author | Christian Cleberg <[email protected]> | 2026-03-17 23:19:43 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-17 23:19:43 -0500 |
| commit | 32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 (patch) | |
| tree | ee36d421d704e508d5617d803b4c8cbdb4804fe1 /Hutch/Views/Builds/BuildDetailView.swift | |
| parent | 8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff) | |
| download | hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2 hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip | |
v1.0
Diffstat (limited to 'Hutch/Views/Builds/BuildDetailView.swift')
| -rw-r--r-- | Hutch/Views/Builds/BuildDetailView.swift | 383 |
1 files changed, 383 insertions, 0 deletions
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift new file mode 100644 index 0000000..977cd87 --- /dev/null +++ b/Hutch/Views/Builds/BuildDetailView.swift @@ -0,0 +1,383 @@ +import SwiftUI + +struct BuildDetailView: View { + let jobId: Int + + @Environment(AppState.self) private var appState + @State private var viewModel: BuildDetailViewModel? + @State private var rebuiltJobId: Int? + @State private var showEditResubmitSheet = false + @State private var showCancelConfirmation = false + + var body: some View { + Group { + if let viewModel { + detailContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading build…") + } + } + .navigationTitle("Job #\(jobId)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + SRHTShareButton( + url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) }, + target: .build + ) { + Image(systemName: "square.and.arrow.up") + } + } + } + .navigationDestination(isPresented: Binding( + get: { rebuiltJobId != nil }, + set: { isPresented in + if !isPresented { + rebuiltJobId = nil + } + } + )) { + if let rebuiltJobId { + BuildDetailView(jobId: rebuiltJobId) + } + } + .sheet(isPresented: $showEditResubmitSheet) { + if let viewModel, let job = viewModel.job { + EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in + showEditResubmitSheet = false + rebuiltJobId = jobId + } + } + } + .alert("Cancel Build?", isPresented: $showCancelConfirmation) { + Button("Keep Running", role: .cancel) {} + Button("Cancel Build", role: .destructive) { + Task { await viewModel?.cancelJob() } + } + } message: { + Text("The build will stop as soon as possible.") + } + .task { + if viewModel == nil { + let vm = BuildDetailViewModel(jobId: jobId, client: appState.client) + viewModel = vm + await vm.loadJob() + } + } + } + + @ViewBuilder + private func detailContent(_ viewModel: BuildDetailViewModel) -> some View { + if viewModel.isLoading, viewModel.job == nil { + SRHTLoadingStateView(message: "Loading build…") + } else if let error = viewModel.error, viewModel.job == nil { + SRHTErrorStateView( + title: "Couldn't Load Build", + message: error, + retryAction: { await viewModel.loadJob() } + ) + } else if let job = viewModel.job { + List { + // Status & metadata + Section("Details") { + HStack { + Text("Status") + Spacer() + HStack(spacing: 6) { + JobStatusIcon(status: job.status) + Text(job.status.rawValue) + .font(.subheadline.weight(.medium)) + } + } + + if let note = job.note, !note.isEmpty { + LabeledContent("Note", value: note) + } + + if let image = job.image { + LabeledContent("Image", value: image) + } + + if !job.tags.isEmpty { + LabeledContent("Tags", value: job.tags.joined(separator: ", ")) + } + + if let visibility = job.visibility { + LabeledContent("Visibility", value: visibility.rawValue.capitalized) + } + + LabeledContent("Owner", value: job.owner.canonicalName) + LabeledContent("Created", value: job.created.relativeDescription) + LabeledContent("Updated", value: job.updated.relativeDescription) + } + + // Per-task logs + if !job.tasks.isEmpty { + ForEach(job.tasks) { task in + Section { + TaskLogSection(task: task, viewModel: viewModel) + } header: { + HStack(spacing: 6) { + TaskStatusIcon(status: task.status) + Text(task.name) + } + } + } + } + + // Cancel button + if job.status.isCancellable { + Section { + Button(role: .destructive) { + showCancelConfirmation = true + } label: { + HStack { + Text("Cancel Build") + if viewModel.isCancelling { + Spacer() + ProgressView() + } + } + } + .disabled(viewModel.isCancelling) + } + } + + if let manifest = job.manifest, + !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Section { + Button { + Task { + rebuiltJobId = await viewModel.rebuildJob() + } + } label: { + HStack { + Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild") + if viewModel.isRebuilding { + Spacer() + ProgressView() + } + } + } + .disabled(viewModel.isRebuilding) + + Button { + showEditResubmitSheet = true + } label: { + Text("Edit & Resubmit") + } + .disabled(viewModel.isSubmittingEditedBuild) + } footer: { + Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.") + } + } + } + .refreshable { + await viewModel.loadJob() + } + .srhtErrorBanner(error: Binding( + get: { viewModel.error }, + set: { viewModel.error = $0 } + )) + } + } +} + +private struct EditResubmitBuildSheet: View { + let viewModel: BuildDetailViewModel + let job: JobDetail + 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 + @State private var secrets = false + @State private var execute = true + @State private var visibility: Visibility + + 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 ?? "") + _tagsText = State(initialValue: job.tags.joined(separator: ", ")) + _note = State(initialValue: job.note ?? "") + _visibility = State(initialValue: job.visibility ?? .public) + } + + var body: some View { + NavigationStack { + Form { + Section("Build Manifest") { + TextField("Build manifest", text: $manifest, axis: .vertical) + .font(.system(.body, design: .monospaced)) + .lineLimit(12...24) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section("Build Options") { + TextField("Note (optional)", text: $note) + TextField("Tags (comma-separated, optional)", text: $tagsText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Picker("Visibility", selection: $visibility) { + Text("Public").tag(Visibility.public) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.private) + } + Toggle("Start build now", isOn: $execute) + Toggle("Allow build secrets", isOn: $secrets) + } + + Section { + Text("This submits a new build. “Start build now” and “Allow build secrets” use local defaults because the current job does not include those original values.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let error = viewModel.error { + Section { + Label { + Text(error) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + .foregroundStyle(.red) + } + } + } + .navigationTitle("Edit & Resubmit") + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + viewModelBindable.error = nil + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + viewModelBindable.error = nil + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + let tags = tagsText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if let jobId = await viewModel.submitBuild( + manifest: manifest, + tags: tags, + note: note, + secrets: secrets, + execute: execute, + visibility: visibility + ) { + onSubmitted(jobId) + } + } + } label: { + if viewModel.isSubmittingEditedBuild { + ProgressView() + .controlSize(.small) + } else { + Text("Submit Build") + } + } + .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild) + } + } + } + } +} + +// MARK: - Task Log Section + +private struct TaskLogSection: View { + let task: BuildTask + let viewModel: BuildDetailViewModel + + @State private var isExpanded: Bool + + init(task: BuildTask, viewModel: BuildDetailViewModel) { + self.task = task + self.viewModel = viewModel + self._isExpanded = State(initialValue: task.status == .failed) + } + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + if viewModel.loadingTaskLogs.contains(task.name) { + HStack { + Spacer() + ProgressView("Loading log…") + Spacer() + } + } else if let logText = viewModel.taskLogs[task.name] { + ScrollView(.horizontal, showsIndicators: false) { + Text(logText) + .font(.caption2.monospaced()) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else if task.log == nil { + Text("No log available.") + .foregroundStyle(.secondary) + } + } label: { + HStack { + Text(task.status.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .task { + if isExpanded { + await viewModel.loadTaskLog(task: task) + } + } + .onChange(of: isExpanded) { _, expanded in + if expanded { + Task { await viewModel.loadTaskLog(task: task) } + } + } + } +} + +// MARK: - Task Status Icon + +private struct TaskStatusIcon: View { + let status: TaskStatus + + var body: some View { + Image(systemName: iconName) + .foregroundStyle(color) + .frame(width: 20) + } + + private var iconName: String { + switch status { + case .success: "checkmark.circle.fill" + case .failed: "xmark.circle.fill" + case .running: "arrow.trianglehead.2.clockwise.rotate.90" + case .pending: "circle.dashed" + case .skipped: "forward.circle.fill" + } + } + + private var color: Color { + switch status { + case .success: .green + case .failed: .red + case .running: .yellow + case .pending: .gray + case .skipped: .secondary + } + } +} |
