Commit bc351b2fcd
Unsigned
Layout: unified · split
Hutch/Views/Builds/BuildDetailView.swift +27 −55
| @@ -6,6 +6,7 @@ struct BuildDetailView: View { | |||
| 6 | @Environment(AppState.self) private var appState | 6 | @Environment(AppState.self) private var appState |
| 7 | @State private var viewModel: BuildDetailViewModel? | 7 | @State private var viewModel: BuildDetailViewModel? |
| 8 | @State private var rebuiltJobId: Int? | 8 | @State private var rebuiltJobId: Int? |
| 9 | @State private var selectedTask: BuildTask? | ||
| 9 | @State private var showEditResubmitSheet = false | 10 | @State private var showEditResubmitSheet = false |
| 10 | @State private var showCancelConfirmation = false | 11 | @State private var showCancelConfirmation = false |
| 11 | 12 | ||
| @@ -41,6 +42,18 @@ struct BuildDetailView: View { | |||
| 41 | BuildDetailView(jobId: rebuiltJobId) | 42 | BuildDetailView(jobId: rebuiltJobId) |
| 42 | } | 43 | } |
| 43 | } | 44 | } |
| 45 | .navigationDestination(isPresented: Binding( | ||
| 46 | get: { selectedTask != nil }, | ||
| 47 | set: { isPresented in | ||
| 48 | if !isPresented { | ||
| 49 | selectedTask = nil | ||
| 50 | } | ||
| 51 | } | ||
| 52 | )) { | ||
| 53 | if let selectedTask, let viewModel { | ||
| 54 | BuildTaskLogView(task: selectedTask, viewModel: viewModel) | ||
| 55 | } | ||
| 56 | } | ||
| 44 | .sheet(isPresented: $showEditResubmitSheet) { | 57 | .sheet(isPresented: $showEditResubmitSheet) { |
| 45 | if let viewModel, let job = viewModel.job { | 58 | if let viewModel, let job = viewModel.job { |
| 46 | EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in | 59 | EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in |
| @@ -115,7 +128,20 @@ struct BuildDetailView: View { | |||
| 115 | if !job.tasks.isEmpty { | 128 | if !job.tasks.isEmpty { |
| 116 | ForEach(job.tasks) { task in | 129 | ForEach(job.tasks) { task in |
| 117 | Section { | 130 | Section { |
| 118 | TaskLogSection(task: task, viewModel: viewModel) | 131 | Button { |
| 132 | selectedTask = task | ||
| 133 | } label: { | ||
| 134 | HStack { | ||
| 135 | Text(task.status.rawValue.capitalized) | ||
| 136 | .font(.subheadline) | ||
| 137 | .foregroundStyle(.secondary) | ||
| 138 | Spacer() | ||
| 139 | Image(systemName: "chevron.right") | ||
| 140 | .font(.caption) | ||
| 141 | .foregroundStyle(.tertiary) | ||
| 142 | } | ||
| 143 | } | ||
| 144 | .foregroundStyle(.primary) | ||
| 119 | } header: { | 145 | } header: { |
| 120 | HStack(spacing: 6) { | 146 | HStack(spacing: 6) { |
| 121 | TaskStatusIcon(status: task.status) | 147 | TaskStatusIcon(status: task.status) |
| @@ -296,60 +322,6 @@ private struct EditResubmitBuildSheet: View { | |||
| 296 | } | 322 | } |
| 297 | } | 323 | } |
| 298 | 324 | ||
| 299 | // MARK: - Task Log Section | ||
| 300 | |||
| 301 | private 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 | 325 | // MARK: - Task Status Icon |
| 354 | 326 | ||
| 355 | private struct TaskStatusIcon: View { | 327 | private struct TaskStatusIcon: View { |
Hutch/Views/Builds/BuildTaskLogView.swift added +55
| @@ -0,0 +1,55 @@ | |||
| 1 | import SwiftUI | ||
| 2 | import UIKit | ||
| 3 | |||
| 4 | struct BuildTaskLogView: View { | ||
| 5 | let task: BuildTask | ||
| 6 | let viewModel: BuildDetailViewModel | ||
| 7 | |||
| 8 | var body: some View { | ||
| 9 | Group { | ||
| 10 | if viewModel.loadingTaskLogs.contains(task.logCacheKey) { | ||
| 11 | SRHTLoadingStateView(message: "Loading log…") | ||
| 12 | } else if let logText = viewModel.taskLogs[task.logCacheKey] { | ||
| 13 | GeometryReader { geometry in | ||
| 14 | ScrollView([.vertical, .horizontal]) { | ||
| 15 | Text(logText) | ||
| 16 | .font(.caption2.monospaced()) | ||
| 17 | .textSelection(.enabled) | ||
| 18 | .padding() | ||
| 19 | .frame( | ||
| 20 | minWidth: geometry.size.width, | ||
| 21 | minHeight: geometry.size.height, | ||
| 22 | alignment: .topLeading | ||
| 23 | ) | ||
| 24 | } | ||
| 25 | } | ||
| 26 | .toolbar { | ||
| 27 | ToolbarItem(placement: .topBarTrailing) { | ||
| 28 | ShareLink(item: logText) | ||
| 29 | } | ||
| 30 | ToolbarItem(placement: .topBarTrailing) { | ||
| 31 | Button { | ||
| 32 | UIPasteboard.general.string = logText | ||
| 33 | } label: { | ||
| 34 | Image(systemName: "doc.on.doc") | ||
| 35 | } | ||
| 36 | .accessibilityLabel("Copy log to clipboard") | ||
| 37 | } | ||
| 38 | } | ||
| 39 | } else if task.log == nil { | ||
| 40 | ContentUnavailableView( | ||
| 41 | "No Log", | ||
| 42 | systemImage: "doc.text", | ||
| 43 | description: Text("This task has no log output.") | ||
| 44 | ) | ||
| 45 | } else { | ||
| 46 | SRHTLoadingStateView(message: "Loading log…") | ||
| 47 | } | ||
| 48 | } | ||
| 49 | .navigationTitle(task.name) | ||
| 50 | .navigationBarTitleDisplayMode(.inline) | ||
| 51 | .task { | ||
| 52 | await viewModel.loadTaskLog(task: task) | ||
| 53 | } | ||
| 54 | } | ||
| 55 | } | ||