From 0e461a382257979037e8927e5f6b468635b0a7fe Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 13 Apr 2026 09:57:23 -0500 Subject: 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 --- Hutch/Views/Builds/BuildDetailView.swift | 76 +++++++++++++++++++++++++-- Hutch/Views/Builds/BuildDetailViewModel.swift | 49 ++++++++++++++++- Hutch/Views/Builds/BuildListView.swift | 23 ++++++++ Hutch/Views/Builds/BuildTaskLogView.swift | 5 +- 4 files changed, 144 insertions(+), 9 deletions(-) (limited to 'Hutch/Views/Builds') 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") } -- cgit v1.2.3