krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.9.1: Hutch/Views/Builds/BuildDetailView.swift · raw
1import SwiftUI
2
3struct BuildDetailView: View {
4 let jobId: Int
5
6 @Environment(AppState.self) private var appState
7 @State private var viewModel: BuildDetailViewModel?
8 @State private var rebuiltJobId: Int?
9 @State private var selectedTaskName: String?
10 @State private var showEditResubmitSheet = false
11 @State private var showCancelConfirmation = false
12
13 private var isPresentingLogSheet: Bool {
14 selectedTaskName != nil
15 }
16
17 var body: some View {
18 Group {
19 if let viewModel {
20 detailContent(viewModel)
21 } else {
22 SRHTLoadingStateView(message: "Loading build…")
23 }
24 }
25 .navigationTitle("Job #\(jobId)")
26 .navigationBarTitleDisplayMode(.inline)
27 .toolbar {
28 ToolbarItem(placement: .topBarTrailing) {
29 SRHTShareButton(
30 url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) },
31 target: .build
32 ) {
33 Image(systemName: "square.and.arrow.up")
34 }
35 }
36 }
37 .navigationDestination(isPresented: Binding(
38 get: { rebuiltJobId != nil },
39 set: { isPresented in
40 if !isPresented {
41 rebuiltJobId = nil
42 }
43 }
44 )) {
45 if let rebuiltJobId {
46 BuildDetailView(jobId: rebuiltJobId)
47 }
48 }
49 .sheet(isPresented: $showEditResubmitSheet) {
50 if let viewModel, let job = viewModel.job {
51 EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
52 showEditResubmitSheet = false
53 rebuiltJobId = jobId
54 }
55 }
56 }
57 .sheet(isPresented: Binding(
58 get: { selectedTaskName != nil },
59 set: { isPresented in
60 if !isPresented {
61 selectedTaskName = nil
62 }
63 }
64 )) {
65 if let selectedTaskName, let viewModel {
66 NavigationStack {
67 BuildTaskLogView(taskName: selectedTaskName, viewModel: viewModel)
68 .toolbar {
69 ToolbarItem(placement: .cancellationAction) {
70 Button("Done") {
71 self.selectedTaskName = nil
72 }
73 }
74 }
75 }
76 } else {
77 NavigationStack {
78 SRHTLoadingStateView(message: "Loading…")
79 .toolbar {
80 ToolbarItem(placement: .cancellationAction) {
81 Button("Done") {
82 self.selectedTaskName = nil
83 }
84 }
85 }
86 }
87 }
88 }
89 .alert("Cancel Build?", isPresented: $showCancelConfirmation) {
90 Button("Keep Running", role: .cancel) {
91 // Alert dismissal is implicit; no additional action required.
92 }
93 Button("Cancel Build", role: .destructive) {
94 Task { await viewModel?.cancelJob() }
95 }
96 } message: {
97 Text("The build will stop as soon as possible.")
98 }
99 .task {
100 if viewModel == nil {
101 let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
102 viewModel = vm
103 await vm.loadJob()
104 vm.startAutoRefresh()
105 }
106 }
107 .onAppear {
108 viewModel?.startAutoRefresh()
109 }
110 .onDisappear {
111 guard !isPresentingLogSheet else { return }
112 viewModel?.stopAutoRefresh()
113 }
114 }
115
116 @ViewBuilder
117 private func detailContent(_ viewModel: BuildDetailViewModel) -> some View {
118 if viewModel.isLoading, viewModel.job == nil {
119 SRHTLoadingStateView(message: "Loading build…")
120 } else if let error = viewModel.error, viewModel.job == nil {
121 SRHTErrorStateView(
122 title: "Couldn't Load Build",
123 message: error,
124 retryAction: { await viewModel.loadJob() }
125 )
126 } else if let job = viewModel.job {
127 List {
128 // Status & metadata
129 Section("Details") {
130 HStack {
131 Text("Status")
132 Spacer()
133 HStack(spacing: 6) {
134 JobStatusIcon(status: job.status)
135 Text(job.status.rawValue)
136 .font(.subheadline.weight(.medium))
137 }
138 }
139
140 if let note = job.note, !note.isEmpty {
141 LabeledContent("Note", value: note)
142 }
143
144 if let image = job.image {
145 LabeledContent("Image", value: image)
146 }
147
148 if !job.tags.isEmpty {
149 LabeledContent("Tags", value: job.tags.joined(separator: ", "))
150 }
151
152 if let visibility = job.visibility {
153 LabeledContent("Visibility", value: visibility.rawValue.capitalized)
154 }
155
156 LabeledContent("Owner", value: job.owner.canonicalName)
157 LabeledContent("Created", value: job.created.relativeDescription)
158 LabeledContent("Updated", value: job.updated.relativeDescription)
159 }
160
161 // Per-task logs
162 if !job.tasks.isEmpty {
163 ForEach(job.tasks) { task in
164 Section {
165 Button {
166 selectedTaskName = task.name
167 } label: {
168 HStack {
169 Text(task.status.rawValue.capitalized)
170 .font(.subheadline)
171 .foregroundStyle(.secondary)
172 Spacer()
173 Image(systemName: "chevron.right")
174 .font(.caption)
175 .foregroundStyle(.tertiary)
176 }
177 }
178 .foregroundStyle(.primary)
179 } header: {
180 HStack(spacing: 6) {
181 TaskStatusIcon(status: task.status)
182 Text(task.name)
183 }
184 }
185 }
186 }
187
188 // Cancel button
189 if job.status.isCancellable {
190 Section {
191 Button(role: .destructive) {
192 showCancelConfirmation = true
193 } label: {
194 HStack {
195 Text("Cancel Build")
196 if viewModel.isCancelling {
197 Spacer()
198 ProgressView()
199 }
200 }
201 }
202 .disabled(viewModel.isCancelling)
203 }
204 }
205
206 if let manifest = job.manifest,
207 !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
208 Section {
209 Button {
210 Task {
211 rebuiltJobId = await viewModel.rebuildJob()
212 }
213 } label: {
214 HStack {
215 Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild")
216 if viewModel.isRebuilding {
217 Spacer()
218 ProgressView()
219 }
220 }
221 }
222 .disabled(viewModel.isRebuilding)
223
224 Button {
225 showEditResubmitSheet = true
226 } label: {
227 Text("Edit & Resubmit")
228 }
229 .disabled(viewModel.isSubmittingEditedBuild)
230 } footer: {
231 Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.")
232 }
233 }
234 }
235 .refreshable {
236 await viewModel.loadJob()
237 }
238 .srhtErrorBanner(error: Binding(
239 get: { viewModel.error },
240 set: { viewModel.error = $0 }
241 ))
242 }
243 }
244}
245
246private struct EditResubmitBuildSheet: View {
247 let viewModel: BuildDetailViewModel
248 let job: JobDetail
249 let onSubmitted: (Int) -> Void
250
251 @Environment(\.dismiss) private var dismiss
252 @Bindable var viewModelBindable: BuildDetailViewModel
253 @State private var manifest: String
254 @State private var tagsText: String
255 @State private var note: String
256 @State private var secrets = false
257 @State private var execute = true
258 @State private var visibility: Visibility
259
260 init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
261 self.viewModel = viewModel
262 self._viewModelBindable = Bindable(viewModel)
263 self.job = job
264 self.onSubmitted = onSubmitted
265 _manifest = State(initialValue: job.manifest ?? "")
266 _tagsText = State(initialValue: job.tags.joined(separator: ", "))
267 _note = State(initialValue: job.note ?? "")
268 _visibility = State(initialValue: job.visibility ?? .public)
269 }
270
271 var body: some View {
272 NavigationStack {
273 Form {
274 Section("Build Manifest") {
275 TextField("Build manifest", text: $manifest, axis: .vertical)
276 .font(.system(.body, design: .monospaced))
277 .lineLimit(12...24)
278 .textInputAutocapitalization(.never)
279 .autocorrectionDisabled()
280 }
281
282 Section("Build Options") {
283 TextField("Note (optional)", text: $note)
284 TextField("Tags (comma-separated, optional)", text: $tagsText)
285 .textInputAutocapitalization(.never)
286 .autocorrectionDisabled()
287 Picker("Visibility", selection: $visibility) {
288 Text("Public").tag(Visibility.public)
289 Text("Unlisted").tag(Visibility.unlisted)
290 Text("Private").tag(Visibility.private)
291 }
292 Toggle("Start build now", isOn: $execute)
293 Toggle("Allow build secrets", isOn: $secrets)
294 }
295
296 Section {
297 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.")
298 .font(.footnote)
299 .foregroundStyle(.secondary)
300 }
301
302 if let error = viewModel.error {
303 Section {
304 Label {
305 Text(error)
306 } icon: {
307 Image(systemName: "exclamationmark.triangle.fill")
308 .foregroundStyle(.red)
309 }
310 .foregroundStyle(.red)
311 }
312 }
313 }
314 .navigationTitle("Edit & Resubmit")
315 .navigationBarTitleDisplayMode(.inline)
316 .onDisappear {
317 viewModelBindable.error = nil
318 }
319 .toolbar {
320 ToolbarItem(placement: .cancellationAction) {
321 Button("Cancel") {
322 viewModelBindable.error = nil
323 dismiss()
324 }
325 }
326 ToolbarItem(placement: .confirmationAction) {
327 Button {
328 Task {
329 let tags = tagsText
330 .split(separator: ",")
331 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
332 .filter { !$0.isEmpty }
333 if let jobId = await viewModel.submitBuild(
334 manifest: manifest,
335 tags: tags,
336 note: note,
337 secrets: secrets,
338 execute: execute,
339 visibility: visibility
340 ) {
341 onSubmitted(jobId)
342 }
343 }
344 } label: {
345 if viewModel.isSubmittingEditedBuild {
346 ProgressView()
347 .controlSize(.small)
348 } else {
349 Text("Submit Build")
350 }
351 }
352 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild)
353 }
354 }
355 }
356 }
357}
358
359// MARK: - Task Status Icon
360
361private struct TaskStatusIcon: View {
362 let status: TaskStatus
363
364 var body: some View {
365 Image(systemName: iconName)
366 .foregroundStyle(color)
367 .frame(width: 20)
368 }
369
370 private var iconName: String {
371 switch status {
372 case .success: "checkmark.circle.fill"
373 case .failed: "xmark.circle.fill"
374 case .running: "arrow.trianglehead.2.clockwise.rotate.90"
375 case .pending: "circle.dashed"
376 case .skipped: "forward.circle.fill"
377 }
378 }
379
380 private var color: Color {
381 switch status {
382 case .success: .green
383 case .failed: .red
384 case .running: .yellow
385 case .pending: .gray
386 case .skipped: .secondary
387 }
388 }
389}