Build iOS App / Build .ipa and publish source (push) Failing after 16s
The horizontal paging ScrollView squished the markdown text and the TabView broke navigation. Revert to the plain vertical ScrollView + switch (always rendered correctly). Swipe-to-change now lives on the bottom tab bar itself (simultaneous DragGesture), so tapping tabs still works. Rewrite MarkdownText line-based: each source line renders on its own line (AttributedString collapses single newlines into spaces), with heading/bullet/code handling. Bump to 3.3.
343 lines
11 KiB
Swift
343 lines
11 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - Conversation
|
||
|
||
struct ConversationTab: View {
|
||
@ObservedObject var vm: PRDetailViewModel
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
if vm.reviews.count > 0 {
|
||
Text("Reviews")
|
||
.font(.headline)
|
||
ForEach(vm.reviews) { review in
|
||
reviewRow(review)
|
||
}
|
||
}
|
||
Text("Comments")
|
||
.font(.headline)
|
||
if vm.comments.isEmpty {
|
||
Text("No comments yet.")
|
||
.font(.callout)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
ForEach(vm.comments) { comment in
|
||
commentRow(comment)
|
||
}
|
||
Text("Inline file comments are not shown — the Gitea API does not expose them.")
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
.padding(.top, 8)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding()
|
||
}
|
||
|
||
private func reviewRow(_ review: PullReview) -> some View {
|
||
HStack(alignment: .top, spacing: 8) {
|
||
Text(icon)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("\(review.user?.login ?? "unknown") \(verb)")
|
||
.font(.subheadline.weight(.semibold))
|
||
if let body = review.body, !body.isEmpty {
|
||
Text(body).font(.callout)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
private func commentRow(_ comment: IssueComment) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Text(comment.user?.login ?? "unknown")
|
||
.font(.subheadline.weight(.semibold))
|
||
if let created = comment.createdAt {
|
||
Text(created, style: .relative)
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
if let body = comment.body, !body.isEmpty {
|
||
Text(body).font(.callout)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(8)
|
||
.background(Color(.secondarySystemBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
private var icon: String {
|
||
switch vm.reviewState {
|
||
case .approved: return "✅"
|
||
case .changesRequested: return "🔴"
|
||
default: return "💬"
|
||
}
|
||
}
|
||
private var verb: String {
|
||
switch vm.reviewState {
|
||
case .approved: return "approved"
|
||
case .changesRequested: return "requested changes"
|
||
default: return "reviewed"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Files
|
||
|
||
struct FilesTab: View {
|
||
@ObservedObject var vm: PRDetailViewModel
|
||
|
||
var body: some View {
|
||
if vm.files.isEmpty {
|
||
Text("No changed files.")
|
||
.font(.callout)
|
||
.foregroundColor(.secondary)
|
||
.frame(maxWidth: .infinity)
|
||
.padding()
|
||
} else {
|
||
LazyVStack(spacing: 0) {
|
||
ForEach(vm.files) { file in
|
||
NavigationLink(value: file) {
|
||
fileRow(file)
|
||
}
|
||
Divider()
|
||
}
|
||
}
|
||
.navigationDestination(for: ChangedFile.self) { file in
|
||
DiffView(repo: vm.repo, prNumber: vm.prNumber, file: file)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func fileRow(_ file: ChangedFile) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Text(statusIcon(file))
|
||
Text(file.filename)
|
||
.font(.body)
|
||
.lineLimit(1)
|
||
Spacer()
|
||
}
|
||
HStack(spacing: 8) {
|
||
Text("+\(file.additions)").foregroundColor(.green)
|
||
.font(.caption2.monospacedDigit())
|
||
Text("−\(file.deletions)").foregroundColor(.red)
|
||
.font(.caption2.monospacedDigit())
|
||
Spacer()
|
||
}
|
||
}
|
||
.padding(.vertical, 6)
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
private func statusIcon(_ file: ChangedFile) -> String {
|
||
switch file.status ?? "" {
|
||
case "added": return "+"
|
||
case "removed": return "-"
|
||
case "renamed": return "→"
|
||
default: return "✎"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Overview
|
||
|
||
/// Renders the PR body as markdown. Uses SwiftUI's built-in
|
||
/// `AttributedString(markdown:)` so headings, bold, italic, links and inline
|
||
/// code render without a third-party dependency.
|
||
struct OverviewTab: View {
|
||
let pr: PullRequest
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
if let body = pr.body, !body.isEmpty {
|
||
MarkdownText(body)
|
||
} else {
|
||
Text("No description.")
|
||
.font(.callout)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
/// A Text view that renders a markdown string, falling back to plain text if
|
||
/// the markdown can't be parsed.
|
||
///
|
||
/// Line-based: each source line renders on its own line (AttributedString
|
||
/// collapses single newlines into spaces, which squishes PR bodies that use
|
||
/// one-line-per-key formatting). Handles headings, bullets, and code fences;
|
||
/// inline formatting (bold, italic, links, inline code) via AttributedString.
|
||
struct MarkdownText: View {
|
||
let markdown: String
|
||
|
||
init(_ markdown: String) {
|
||
self.markdown = markdown
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
let lines = markdown.components(separatedBy: "\n")
|
||
var inCodeBlock = false
|
||
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||
if line.hasPrefix("```") {
|
||
inCodeBlock.toggle()
|
||
} else if inCodeBlock {
|
||
Text(line)
|
||
.font(.caption.monospaced())
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(4)
|
||
.background(Color(.secondarySystemBackground))
|
||
} else if line.hasPrefix("# ") || line.hasPrefix("## ") || line.hasPrefix("### ") {
|
||
heading(line)
|
||
} else if line.hasPrefix("- ") || line.hasPrefix("* ") {
|
||
bullet(line)
|
||
} else if line.trimmingCharacters(in: .whitespaces).isEmpty {
|
||
Spacer(minLength: 4)
|
||
} else {
|
||
inline(line)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
private func heading(_ line: String) -> some View {
|
||
let level = line.prefix(while: { $0 == "#" }).count
|
||
let text = line.dropFirst(level).trimmingCharacters(in: .whitespaces)
|
||
return Text(text)
|
||
.font(level == 1 ? .title2.bold() : level == 2 ? .title3.bold() : .headline)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
private func bullet(_ line: String) -> some View {
|
||
HStack(alignment: .top, spacing: 6) {
|
||
Text("•")
|
||
inline(String(line.dropFirst(2)))
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
private func inline(_ text: String) -> some View {
|
||
if let attributed = try? AttributedString(markdown: text) {
|
||
Text(attributed)
|
||
.font(.callout)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
} else {
|
||
Text(text)
|
||
.font(.callout)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Checks
|
||
|
||
struct ChecksTab: View {
|
||
@ObservedObject var vm: PRDetailViewModel
|
||
let onRerun: (ActionRun) -> Void
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
if vm.runs.isEmpty {
|
||
Text("No workflow runs for this head commit.")
|
||
.font(.callout)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
ForEach(vm.runs) { run in
|
||
runCard(run)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding()
|
||
}
|
||
|
||
private func runCard(_ run: ActionRun) -> some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
CICircle(status: statusFor(run))
|
||
Text(run.name ?? "workflow")
|
||
.font(.subheadline.weight(.semibold))
|
||
Spacer()
|
||
if let started = run.createdAt {
|
||
Text(started, style: .relative)
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
if let jobs = vm.jobs[run.id], !jobs.isEmpty {
|
||
ForEach(jobs) { job in
|
||
HStack {
|
||
CICircle(status: statusFor(job))
|
||
Text(job.name).font(.caption)
|
||
Spacer()
|
||
if isFailed(job) {
|
||
Button("Re-run") {
|
||
onRerun(run)
|
||
}
|
||
.font(.caption)
|
||
.buttonStyle(.bordered)
|
||
}
|
||
if hasRun(job) {
|
||
NavigationLink {
|
||
JobLogsView(repo: vm.repo, runID: run.id, jobID: job.id,
|
||
jobName: job.name)
|
||
} label: {
|
||
Text("Logs")
|
||
.font(.caption)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(10)
|
||
.background(Color(.secondarySystemBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
|
||
private func statusFor(_ run: ActionRun) -> CIStatus {
|
||
let c = (run.conclusion ?? "").lowercased()
|
||
let s = (run.status ?? "").lowercased()
|
||
if s == "running" || s == "queued" || s == "waiting" { return .running }
|
||
switch c {
|
||
case "success": return .success
|
||
case "failure": return .failure
|
||
case "cancelled": return .cancelled
|
||
default: return .unknown
|
||
}
|
||
}
|
||
|
||
private func statusFor(_ job: ActionJob) -> CIStatus {
|
||
let c = (job.conclusion ?? "").lowercased()
|
||
let s = job.status.lowercased()
|
||
if s == "running" || s == "queued" { return .running }
|
||
switch c {
|
||
case "success": return .success
|
||
case "failure": return .failure
|
||
case "cancelled": return .cancelled
|
||
default: return .unknown
|
||
}
|
||
}
|
||
|
||
private func isFailed(_ job: ActionJob) -> Bool {
|
||
let c = (job.conclusion ?? "").lowercased()
|
||
return c == "failure" || c == "cancelled"
|
||
}
|
||
|
||
/// A job has run only once it has a terminal conclusion. Queued/running
|
||
/// jobs (grey, no logs yet) hide the Logs button.
|
||
private func hasRun(_ job: ActionJob) -> Bool {
|
||
let s = job.status.lowercased()
|
||
if s == "queued" || s == "running" || s == "waiting" || s == "requested" {
|
||
return false
|
||
}
|
||
return (job.conclusion ?? "").lowercased() != ""
|
||
}
|
||
}
|