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