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 | |
| parent | 8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff) | |
| download | hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2 hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip | |
v1.0
Diffstat (limited to 'Hutch/Views/Builds')
| -rw-r--r-- | Hutch/Views/Builds/BuildDetailView.swift | 383 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildDetailViewModel.swift | 235 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListView.swift | 217 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListViewModel.swift | 220 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildRowView.swift | 106 |
5 files changed, 1161 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 + } + } +} diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift new file mode 100644 index 0000000..c14e47b --- /dev/null +++ b/Hutch/Views/Builds/BuildDetailViewModel.swift @@ -0,0 +1,235 @@ +import Foundation + +// MARK: - Response types + +private struct JobDetailResponse: Decodable, Sendable { + let job: JobDetail +} + +private struct CancelResponse: Decodable, Sendable { + let cancel: CancelResult +} + +private struct CancelResult: Decodable, Sendable { + let id: Int +} + +private struct SubmitJobResponse: Decodable, Sendable { + let submit: SubmittedJob +} + +private struct SubmittedJob: Decodable, Sendable { + let id: Int +} + +// MARK: - View Model + +@Observable +@MainActor +final class BuildDetailViewModel { + + let jobId: Int + private let client: SRHTClient + + private(set) var job: JobDetail? + private(set) var isLoading = false + private(set) var taskLogs: [String: String] = [:] + private(set) var loadingTaskLogs: Set<String> = [] + private(set) var isCancelling = false + private(set) var isRebuilding = false + private(set) var isSubmittingEditedBuild = false + var error: String? + + init(jobId: Int, client: SRHTClient) { + self.jobId = jobId + self.client = client + } + + // MARK: - Queries + + private static let detailQuery = """ + query job($id: Int!) { + job(id: $id) { + id + created + updated + status + note + tags + visibility + image + manifest + tasks { name status log { fullURL } } + log { fullURL } + owner { canonicalName } + } + } + """ + + private static let cancelMutation = """ + mutation cancel($id: Int!) { + cancel(jobId: $id) { + id + } + } + """ + + private static let submitMutation = """ + mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) { + submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) { + id + } + } + """ + + private static let editableSubmitMutation = """ + mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) { + submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) { + id + } + } + """ + + // MARK: - Public API + + func loadJob() async { + guard !isLoading else { return } + isLoading = true + error = nil + + do { + let result = try await client.execute( + service: .builds, + query: Self.detailQuery, + variables: ["id": jobId], + responseType: JobDetailResponse.self + ) + job = result.job + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + func loadTaskLog(task: BuildTask) async { + guard let log = task.log, + let logURL = URL(string: log.fullURL), + !loadingTaskLogs.contains(task.name), + taskLogs[task.name] == nil else { return } + loadingTaskLogs.insert(task.name) + + do { + taskLogs[task.name] = try await client.fetchText(url: logURL) + } catch { + self.error = error.localizedDescription + } + + loadingTaskLogs.remove(task.name) + } + + func cancelJob() async { + guard let job, job.status.isCancellable, !isCancelling else { return } + isCancelling = true + error = nil + + do { + _ = try await client.execute( + service: .builds, + query: Self.cancelMutation, + variables: ["id": jobId], + responseType: CancelResponse.self + ) + // Reload job to get updated status. + await loadJob() + } catch { + self.error = error.localizedDescription + } + + isCancelling = false + } + + func rebuildJob() async -> Int? { + guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else { + return nil + } + + isRebuilding = true + error = nil + defer { isRebuilding = false } + + var variables: [String: any Sendable] = [ + "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines) + ] + if !job.tags.isEmpty { + variables["tags"] = job.tags + } + if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines) + } + if let visibility = job.visibility { + variables["visibility"] = visibility.rawValue + } + + do { + let result = try await client.execute( + service: .builds, + query: Self.submitMutation, + variables: variables, + responseType: SubmitJobResponse.self + ) + return result.submit.id + } catch { + self.error = error.localizedDescription + return nil + } + } + + func submitBuild( + manifest: String, + tags: [String], + note: String, + secrets: Bool, + execute: Bool, + visibility: Visibility + ) async -> Int? { + guard !isSubmittingEditedBuild else { return nil } + + let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedManifest.isEmpty else { + error = "Paste a build manifest." + return nil + } + + isSubmittingEditedBuild = true + error = nil + defer { isSubmittingEditedBuild = false } + + var variables: [String: any Sendable] = [ + "manifest": trimmedManifest, + "secrets": secrets, + "execute": execute, + "visibility": visibility.rawValue + ] + if !tags.isEmpty { + variables["tags"] = tags + } + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedNote.isEmpty { + variables["note"] = trimmedNote + } + + do { + let result = try await client.execute( + service: .builds, + query: Self.editableSubmitMutation, + variables: variables, + responseType: SubmitJobResponse.self + ) + return result.submit.id + } catch { + self.error = "Couldn’t submit the build. \(error.localizedDescription)" + return nil + } + } +} diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift new file mode 100644 index 0000000..875a836 --- /dev/null +++ b/Hutch/Views/Builds/BuildListView.swift @@ -0,0 +1,217 @@ +import SwiftUI + +struct BuildListView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: BuildListViewModel? + @State private var showSubmitSheet = false + @State private var submittedJobId: Int? + + var body: some View { + Group { + if let viewModel { + listContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading builds…") + } + } + .navigationTitle("Builds") + .toolbar { + if viewModel != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + showSubmitSheet = true + } label: { + Image(systemName: "plus") + } + } + } + } + .sheet(isPresented: $showSubmitSheet) { + if let viewModel { + SubmitBuildSheet(viewModel: viewModel) { jobId in + showSubmitSheet = false + submittedJobId = jobId + } + } + } + .navigationDestination(for: JobSummary.self) { job in + BuildDetailView(jobId: job.id) + } + .navigationDestination(isPresented: Binding( + get: { submittedJobId != nil }, + set: { isPresented in + if !isPresented { + submittedJobId = nil + } + } + )) { + if let submittedJobId { + BuildDetailView(jobId: submittedJobId) + } + } + .task { + if viewModel == nil { + let vm = BuildListViewModel(client: appState.client) + viewModel = vm + await vm.loadJobs() + } + } + } + + @ViewBuilder + private func listContent(_ viewModel: BuildListViewModel) -> some View { + @Bindable var vm = viewModel + + List { + ForEach(viewModel.jobs) { job in + NavigationLink(value: job) { + BuildRowView(job: job) + } + .task { + await viewModel.loadMoreIfNeeded(currentItem: job) + } + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoading, viewModel.jobs.isEmpty { + SRHTLoadingStateView(message: "Loading builds…") + } else if let error = viewModel.error, viewModel.jobs.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Builds", + message: error, + retryAction: { await viewModel.loadJobs() } + ) + } else if viewModel.jobs.isEmpty, viewModel.error == nil { + ContentUnavailableView( + "No Builds", + systemImage: "hammer", + description: Text("Your build jobs will appear here.") + ) + } + } + .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) { + await viewModel.loadJobs() + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadJobs() + } + } +} + +private struct SubmitBuildSheet: View { + let viewModel: BuildListViewModel + let onSubmitted: (Int) -> Void + + @Environment(\.dismiss) private var dismiss + @Bindable var viewModelBindable: BuildListViewModel + @State private var manifest = "" + @State private var tagsText = "" + @State private var note = "" + @State private var secrets = false + @State private var execute = true + @State private var visibility: Visibility = .public + + init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) { + self.viewModel = viewModel + self._viewModelBindable = Bindable(viewModel) + self.onSubmitted = onSubmitted + } + + var body: some View { + NavigationStack { + Form { + Section("Build Manifest") { + TextField("Paste a 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("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let error = viewModel.error { + Section { + Label { + Text(error) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + .foregroundStyle(.red) + } + } + } + .navigationTitle("Submit Build") + .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.isSubmitting { + ProgressView() + .controlSize(.small) + } else { + Text("Submit Build") + } + } + .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting) + } + } + } + } +} diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift new file mode 100644 index 0000000..8d0f961 --- /dev/null +++ b/Hutch/Views/Builds/BuildListViewModel.swift @@ -0,0 +1,220 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct JobsResponse: Decodable, Sendable { + let jobs: JobsPage +} + +private struct JobsPage: Decodable, Sendable { + let results: [JobSummary] + let cursor: String? +} + +private struct SubmitJobResponse: Decodable, Sendable { + let submit: SubmittedJob +} + +private struct SubmittedJob: Decodable, Sendable { + let id: Int +} + +// MARK: - View Model + +@Observable +@MainActor +final class BuildListViewModel { + + private(set) var jobs: [JobSummary] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var isRefreshing = false + private(set) var isSubmitting = false + var error: String? + + private var cursor: String? + private var hasMore = true + private let client: SRHTClient + + private static let cacheKey = "builds.jobs" + + init(client: SRHTClient) { + self.client = client + } + + // MARK: - Query + + private static let query = """ + query jobs($cursor: Cursor) { + jobs(cursor: $cursor) { + results { + id + created + updated + status + note + tags + visibility + image + tasks { name status } + } + cursor + } + } + """ + + private static let submitMutation = """ + mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) { + submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) { + id + } + } + """ + + // MARK: - Public API + + /// Fetch the first page of jobs. Shows cached data instantly if available, + /// then refreshes from the network in the background. + func loadJobs() async { + // Show cached data immediately on first load + if jobs.isEmpty { + loadFromCache() + } + + if jobs.isEmpty { + isLoading = true + } else { + isRefreshing = true + } + error = nil + cursor = nil + hasMore = true + + do { + let page = try await fetchPage(cursor: nil, useCache: true) + jobs = page.results + cursor = page.cursor + hasMore = page.cursor != nil + } catch { + if jobs.isEmpty { + self.error = error.localizedDescription + } + } + + isLoading = false + isRefreshing = false + } + + func loadMoreIfNeeded(currentItem: JobSummary) async { + guard let last = jobs.last, + last.id == currentItem.id, + hasMore, + !isLoadingMore else { + return + } + + isLoadingMore = true + + do { + let page = try await fetchPage(cursor: cursor, useCache: false) + jobs.append(contentsOf: page.results) + cursor = page.cursor + hasMore = page.cursor != nil + } catch { + self.error = error.localizedDescription + } + + isLoadingMore = false + } + + func submitBuild( + manifest: String, + tags: [String], + note: String, + secrets: Bool, + execute: Bool, + visibility: Visibility + ) async -> Int? { + guard !isSubmitting else { return nil } + + let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedManifest.isEmpty else { + error = "Paste a build manifest." + return nil + } + + isSubmitting = true + error = nil + defer { isSubmitting = false } + + var variables: [String: any Sendable] = [ + "manifest": trimmedManifest, + "secrets": secrets, + "execute": execute, + "visibility": visibility.rawValue + ] + if !tags.isEmpty { + variables["tags"] = tags + } + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedNote.isEmpty { + variables["note"] = trimmedNote + } + + do { + let result = try await client.execute( + service: .builds, + query: Self.submitMutation, + variables: variables, + responseType: SubmitJobResponse.self + ) + await loadJobs() + return result.submit.id + } catch { + self.error = "Couldn’t submit the build. \(error.localizedDescription)" + return nil + } + } + + // MARK: - Private + + private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + if useCache && cursor == nil { + let result = try await client.executeAndCache( + service: .builds, + query: Self.query, + variables: variables.isEmpty ? nil : variables, + responseType: JobsResponse.self, + cacheKey: Self.cacheKey + ) + return result.jobs + } else { + let result = try await client.execute( + service: .builds, + query: Self.query, + variables: variables.isEmpty ? nil : variables, + responseType: JobsResponse.self + ) + return result.jobs + } + } + + private func loadFromCache() { + guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .srhtFlexible + if let response = try? decoder.decode( + GraphQLResponse<JobsResponse>.self, + from: data + ), let page = response.data?.jobs { + jobs = page.results + cursor = page.cursor + hasMore = page.cursor != nil + } + } +} diff --git a/Hutch/Views/Builds/BuildRowView.swift b/Hutch/Views/Builds/BuildRowView.swift new file mode 100644 index 0000000..e2afd27 --- /dev/null +++ b/Hutch/Views/Builds/BuildRowView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +struct BuildRowView: View { + let job: JobSummary + + var body: some View { + HStack(spacing: 12) { + JobStatusIcon(status: job.status) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 4) { + Text(job.displayLabel) + .font(.subheadline) + .lineLimit(1) + + HStack(spacing: 8) { + if let image = job.image { + Text(image) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(job.created.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + } + + if !job.tasks.isEmpty { + TaskProgressView(tasks: job.tasks) + } + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Job Status Icon + +struct JobStatusIcon: View { + let status: JobStatus + + var body: some View { + Image(systemName: iconName) + .foregroundStyle(color) + .symbolEffect(.pulse, isActive: status == .running) + } + + private var iconName: String { + switch status { + case .success: "checkmark.circle.fill" + case .failed, .timeout: "xmark.circle.fill" + case .running: "arrow.trianglehead.2.clockwise.rotate.90" + case .queued: "clock.fill" + case .pending: "circle.dashed" + case .cancelled: "minus.circle.fill" + } + } + + private var color: Color { + switch status { + case .success: .green + case .failed, .timeout: .red + case .running: .yellow + case .queued: .orange + case .pending, .cancelled: .gray + } + } +} + +// MARK: - Task Progress + +struct TaskProgressView: View { + let tasks: [JobTaskSummary] + + var body: some View { + HStack(spacing: 6) { + ProgressView(value: progress, total: 1.0) + .tint(progressColor) + .frame(maxWidth: 80) + + Text("\(completedCount)/\(tasks.count) tasks") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private var completedCount: Int { + tasks.filter { $0.status == .success }.count + } + + private var progress: Double { + tasks.isEmpty ? 0 : Double(completedCount) / Double(tasks.count) + } + + private var progressColor: Color { + if tasks.contains(where: { $0.status == .failed }) { + return .red + } + if completedCount == tasks.count { + return .green + } + return .blue + } +} |
