Files
ios-gitea-prs/GiteaPR/Detail/Tabs.swift
T
Hermes dd0fc84238
Build iOS App / Build .ipa and publish source (push) Failing after 16s
Restore vertical scroll + line-based markdown; swipe on bottom bar; bump to 3.3
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.
2026-08-14 09:43:39 +00:00

343 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() != ""
}
}