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