summaryrefslogtreecommitdiff
path: root/Hutch/Views/Builds
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 09:57:23 -0500
committerChristian Cleberg <[email protected]>2026-04-13 09:57:23 -0500
commit0e461a382257979037e8927e5f6b468635b0a7fe (patch)
tree0aa3a19f6f8f966b0e90f4874ed54359a7898107 /Hutch/Views/Builds
parentee9f2904aa319231b7047fca3cb069f0c07019cd (diff)
downloadhutch-0e461a382257979037e8927e5f6b468635b0a7fe.tar.gz
hutch-0e461a382257979037e8927e5f6b468635b0a7fe.tar.bz2
hutch-0e461a382257979037e8927e5f6b468635b0a7fe.zip
feat: add power user actions
Implements: https://todo.sr.ht/~ccleberg/hutch/52 Implements: https://todo.sr.ht/~ccleberg/hutch/53 Implements: https://todo.sr.ht/~ccleberg/hutch/54
Diffstat (limited to 'Hutch/Views/Builds')
-rw-r--r--Hutch/Views/Builds/BuildDetailView.swift76
-rw-r--r--Hutch/Views/Builds/BuildDetailViewModel.swift49
-rw-r--r--Hutch/Views/Builds/BuildListView.swift23
-rw-r--r--Hutch/Views/Builds/BuildTaskLogView.swift5
4 files changed, 144 insertions, 9 deletions
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
index 1ec9f64..f8ab90d 100644
--- a/Hutch/Views/Builds/BuildDetailView.swift
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -27,7 +27,42 @@ struct BuildDetailView: View {
.navigationTitle("Job #\(jobId)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
- ToolbarItem(placement: .topBarTrailing) {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ if let browserURL = viewModel?.job.flatMap({ SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) }) {
+ Menu {
+ Button {
+ openURL(browserURL)
+ } label: {
+ Label("Open in Browser", systemImage: "safari")
+ }
+
+ Button {
+ appState.copyToPasteboard(browserURL.absoluteString, label: "build URL")
+ } label: {
+ Label("Copy URL", systemImage: "doc.on.doc")
+ }
+
+ if let job = viewModel?.job {
+ Button {
+ appState.copyToPasteboard(String(job.id), label: "job ID")
+ } label: {
+ Label("Copy Job ID", systemImage: "number")
+ }
+
+ if let note = job.note, !note.isEmpty {
+ Button {
+ appState.copyToPasteboard(note, label: "build note")
+ } label: {
+ Label("Copy Note", systemImage: "text.alignleft")
+ }
+ }
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .accessibilityLabel("Build actions")
+ }
+
SRHTShareButton(
url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) },
target: .build
@@ -102,7 +137,11 @@ struct BuildDetailView: View {
if viewModel == nil {
let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
viewModel = vm
- await vm.loadJob()
+ if appState.isDebugModeEnabled {
+ await vm.loadJobWithDebugCapture()
+ } else {
+ await vm.loadJob()
+ }
vm.startAutoRefresh()
}
}
@@ -123,11 +162,10 @@ struct BuildDetailView: View {
SRHTErrorStateView(
title: "Couldn't Load Build",
message: error,
- retryAction: { await viewModel.loadJob() }
+ retryAction: { await reloadDetail(viewModel) }
)
} else if let job = viewModel.job {
List {
- // Status & metadata
Section("Details") {
HStack {
Text("Status")
@@ -160,6 +198,26 @@ struct BuildDetailView: View {
LabeledContent("Updated", value: job.updated.relativeDescription)
}
+ if appState.isDebugModeEnabled {
+ Section("Debug") {
+ DebugTextBlock(
+ title: "Diagnostics",
+ content: """
+ jobId: \(job.id)
+ status: \(job.status.rawValue)
+ tasks: \(job.tasks.count)
+ artifacts: \(job.artifacts.count)
+ owner: \(job.owner.canonicalName)
+ url: \(SRHTWebURL.build(jobId: job.id, ownerCanonicalName: job.owner.canonicalName)?.absoluteString ?? "unavailable")
+ """
+ )
+
+ if let rawJobResponse = viewModel.rawJobResponse {
+ DebugTextBlock(title: "Raw Response", content: rawJobResponse)
+ }
+ }
+ }
+
if let repositoryReference = HomeViewModel.primaryRepositoryReference(in: job.manifest) {
Section("Source") {
Button {
@@ -274,7 +332,7 @@ struct BuildDetailView: View {
}
}
.refreshable {
- await viewModel.loadJob()
+ await reloadDetail(viewModel)
}
.srhtErrorBanner(error: Binding(
get: { viewModel.error },
@@ -301,6 +359,14 @@ struct BuildDetailView: View {
}
}
}
+
+ private func reloadDetail(_ viewModel: BuildDetailViewModel) async {
+ if appState.isDebugModeEnabled {
+ await viewModel.loadJobWithDebugCapture()
+ } else {
+ await viewModel.loadJob()
+ }
+ }
}
private struct BuildArtifactRow: View {
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift
index 9566e0d..dcf70a0 100644
--- a/Hutch/Views/Builds/BuildDetailViewModel.swift
+++ b/Hutch/Views/Builds/BuildDetailViewModel.swift
@@ -28,6 +28,7 @@ private struct SubmittedJob: Decodable, Sendable {
@MainActor
final class BuildDetailViewModel {
private static let autoRefreshInterval: Duration = .seconds(5)
+ private static func cacheKey(for jobId: Int) -> String { "build.detail.\(jobId)" }
let jobId: Int
private let client: SRHTClient
@@ -44,6 +45,7 @@ final class BuildDetailViewModel {
private(set) var isCancelling = false
private(set) var isRebuilding = false
private(set) var isSubmittingEditedBuild = false
+ private(set) var rawJobResponse: String?
var error: String?
/// Transient error shown for action failures (cancel, rebuild, submit).
/// Separate from `error` so auto-refresh doesn't immediately clear it.
@@ -123,6 +125,7 @@ final class BuildDetailViewModel {
guard !isLoading else { return }
isLoading = true
error = nil
+ rawJobResponse = nil
do {
let result = try await client.execute(
@@ -149,6 +152,40 @@ final class BuildDetailViewModel {
isLoading = false
}
+ func loadJobWithDebugCapture() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let cacheKey = Self.cacheKey(for: jobId)
+ let result = try await client.executeAndCache(
+ service: .builds,
+ query: Self.detailQuery,
+ variables: ["id": jobId],
+ responseType: JobDetailResponse.self,
+ cacheKey: cacheKey
+ )
+ rawJobResponse = client.responseCache.get(forKey: cacheKey)
+ .flatMap { String(data: $0, encoding: .utf8) }
+ var loadedJob = result.job
+ loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
+ task.withOrdinal(index)
+ }
+ if job != loadedJob {
+ job = loadedJob
+ }
+
+ if loadedJob.status.isTerminal {
+ stopAutoRefresh()
+ }
+ } catch {
+ self.error = error.userFacingMessage
+ }
+
+ isLoading = false
+ }
+
func loadTaskLog(task: BuildTask) async {
let cacheKey = task.logCacheKey
let jobIsTerminal = job?.status.isTerminal ?? false
@@ -250,7 +287,7 @@ final class BuildDetailViewModel {
variables: ["id": jobId],
responseType: CancelResponse.self
)
- await loadJob()
+ await reloadJobPreservingDebugState()
} catch {
// Revert optimistic update on failure.
self.job = originalJob
@@ -372,6 +409,14 @@ final class BuildDetailViewModel {
self.autoRefreshTask = nil
}
+ private func reloadJobPreservingDebugState() async {
+ if rawJobResponse != nil {
+ await loadJobWithDebugCapture()
+ } else {
+ await loadJob()
+ }
+ }
+
private var shouldAutoRefresh: Bool {
guard let job else { return true }
return !job.status.isTerminal
@@ -385,7 +430,7 @@ final class BuildDetailViewModel {
return
}
- await loadJob()
+ await reloadJobPreservingDebugState()
await loadBuildLog()
}
}
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
index d99d53a..aa98066 100644
--- a/Hutch/Views/Builds/BuildListView.swift
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -136,6 +136,29 @@ struct BuildListView: View {
NavigationLink(value: job) {
BuildRowView(job: job)
}
+ .contextMenu {
+ Button {
+ appState.copyToPasteboard(String(job.id), label: "job ID")
+ } label: {
+ Label("Copy Job ID", systemImage: "doc.on.doc")
+ }
+
+ if let note = job.note, !note.isEmpty {
+ Button {
+ appState.copyToPasteboard(note, label: "build note")
+ } label: {
+ Label("Copy Note", systemImage: "text.alignleft")
+ }
+ }
+
+ if !job.tags.isEmpty {
+ Button {
+ appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags")
+ } label: {
+ Label("Copy Tags", systemImage: "tag")
+ }
+ }
+ }
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if swipeActionsEnabled, job.status.isCancellable {
Button {
diff --git a/Hutch/Views/Builds/BuildTaskLogView.swift b/Hutch/Views/Builds/BuildTaskLogView.swift
index fda69f6..3011e34 100644
--- a/Hutch/Views/Builds/BuildTaskLogView.swift
+++ b/Hutch/Views/Builds/BuildTaskLogView.swift
@@ -1,7 +1,8 @@
import SwiftUI
-import UIKit
struct BuildTaskLogView: View {
+ @Environment(AppState.self) private var appState
+
let taskName: String
let viewModel: BuildDetailViewModel
@@ -32,7 +33,7 @@ struct BuildTaskLogView: View {
}
ToolbarItem(placement: .topBarTrailing) {
Button {
- UIPasteboard.general.string = logText
+ appState.copyToPasteboard(logText, label: "build log")
} label: {
Image(systemName: "doc.on.doc")
}