krz/hutch

an ios client for sourcehut

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

e03791d6c15ee0caac033d1b1b390c5d65a69d57

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-23T01:40:56Z

feat: replace inline task log expansion with a full-screen log view
 Hutch/Views/Builds/BuildDetailView.swift  | 82 ++++++++++---------------------
 Hutch/Views/Builds/BuildTaskLogView.swift | 55 +++++++++++++++++++++
 2 files changed, 82 insertions(+), 55 deletions(-)

diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
index 0479f76..cd2a972 100644
--- a/Hutch/Views/Builds/BuildDetailView.swift
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -6,6 +6,7 @@ struct BuildDetailView: View {
     @Environment(AppState.self) private var appState
     @State private var viewModel: BuildDetailViewModel?
     @State private var rebuiltJobId: Int?
+    @State private var selectedTask: BuildTask?
     @State private var showEditResubmitSheet = false
     @State private var showCancelConfirmation = false
 
@@ -41,6 +42,18 @@ struct BuildDetailView: View {
                 BuildDetailView(jobId: rebuiltJobId)
             }
         }
+        .navigationDestination(isPresented: Binding(
+            get: { selectedTask != nil },
+            set: { isPresented in
+                if !isPresented {
+                    selectedTask = nil
+                }
+            }
+        )) {
+            if let selectedTask, let viewModel {
+                BuildTaskLogView(task: selectedTask, viewModel: viewModel)
+            }
+        }
         .sheet(isPresented: $showEditResubmitSheet) {
             if let viewModel, let job = viewModel.job {
                 EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
@@ -115,7 +128,20 @@ struct BuildDetailView: View {
                 if !job.tasks.isEmpty {
                     ForEach(job.tasks) { task in
                         Section {
-                            TaskLogSection(task: task, viewModel: viewModel)
+                            Button {
+                                selectedTask = task
+                            } label: {
+                                HStack {
+                                    Text(task.status.rawValue.capitalized)
+                                        .font(.subheadline)
+                                        .foregroundStyle(.secondary)
+                                    Spacer()
+                                    Image(systemName: "chevron.right")
+                                        .font(.caption)
+                                        .foregroundStyle(.tertiary)
+                                }
+                            }
+                            .foregroundStyle(.primary)
                         } header: {
                             HStack(spacing: 6) {
                                 TaskStatusIcon(status: task.status)
@@ -296,60 +322,6 @@ private struct EditResubmitBuildSheet: View {
     }
 }
 
-// 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.logCacheKey) {
-                HStack {
-                    Spacer()
-                    ProgressView("Loading log…")
-                    Spacer()
-                }
-            } else if let logText = viewModel.taskLogs[task.logCacheKey] {
-                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 {
diff --git a/Hutch/Views/Builds/BuildTaskLogView.swift b/Hutch/Views/Builds/BuildTaskLogView.swift
new file mode 100644
index 0000000..9ed0bdd
--- /dev/null
+++ b/Hutch/Views/Builds/BuildTaskLogView.swift
@@ -0,0 +1,55 @@
+import SwiftUI
+import UIKit
+
+struct BuildTaskLogView: View {
+    let task: BuildTask
+    let viewModel: BuildDetailViewModel
+
+    var body: some View {
+        Group {
+            if viewModel.loadingTaskLogs.contains(task.logCacheKey) {
+                SRHTLoadingStateView(message: "Loading log…")
+            } else if let logText = viewModel.taskLogs[task.logCacheKey] {
+                GeometryReader { geometry in
+                    ScrollView([.vertical, .horizontal]) {
+                        Text(logText)
+                            .font(.caption2.monospaced())
+                            .textSelection(.enabled)
+                            .padding()
+                            .frame(
+                                minWidth: geometry.size.width,
+                                minHeight: geometry.size.height,
+                                alignment: .topLeading
+                            )
+                    }
+                }
+                .toolbar {
+                    ToolbarItem(placement: .topBarTrailing) {
+                        ShareLink(item: logText)
+                    }
+                    ToolbarItem(placement: .topBarTrailing) {
+                        Button {
+                            UIPasteboard.general.string = logText
+                        } label: {
+                            Image(systemName: "doc.on.doc")
+                        }
+                        .accessibilityLabel("Copy log to clipboard")
+                    }
+                }
+            } else if task.log == nil {
+                ContentUnavailableView(
+                    "No Log",
+                    systemImage: "doc.text",
+                    description: Text("This task has no log output.")
+                )
+            } else {
+                SRHTLoadingStateView(message: "Loading log…")
+            }
+        }
+        .navigationTitle(task.name)
+        .navigationBarTitleDisplayMode(.inline)
+        .task {
+            await viewModel.loadTaskLog(task: task)
+        }
+    }
+}