krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.16.0: 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}