Files
ios-gitea-prs/GiteaPR/Detail/Tabs.swift
T
Hermes a9d408ee85
Build iOS App / Build .ipa and publish source (push) Successful in 25s
Fix compile errors: guard unwrap + ViewBuilder var mutation
- PRDetailView: guard let current = selectedTab ?? .overview fails because
  ?? already unwraps to non-optional. Use let current = selectedTab ?? .overview.
- MarkdownText: var inCodeBlock inside ViewBuilder is not allowed. Precompute
  lines with their code-block state as a computed property on a private struct,
  then iterate via ForEach in the builder. Bump to 3.3.
2026-08-14 09:45:44 +00:00

369 lines
12 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
}
/// One markdown source line with its render type resolved upfront, so the
/// code-block state doesn't have to be mutated inside the ViewBuilder.
private struct Line: Identifiable {
enum Kind { case code, heading, bullet, text, blank }
let id = UUID()
let kind: Kind
let text: String
let level: Int // heading level
}
private var lines: [Line] {
let raw = markdown.components(separatedBy: "\n")
var out: [Line] = []
var inCodeBlock = false
for line in raw {
if line.hasPrefix("```") {
inCodeBlock.toggle()
continue
}
if inCodeBlock {
out.append(Line(kind: .code, text: line, level: 0))
} else if line.hasPrefix("# ") || line.hasPrefix("## ") || line.hasPrefix("### ") {
let level = line.prefix(while: { $0 == "#" }).count
out.append(Line(kind: .heading,
text: String(line.dropFirst(level)).trimmingCharacters(in: .whitespaces),
level: level))
} else if line.hasPrefix("- ") || line.hasPrefix("* ") {
out.append(Line(kind: .bullet,
text: String(line.dropFirst(2)),
level: 0))
} else if line.trimmingCharacters(in: .whitespaces).isEmpty {
out.append(Line(kind: .blank, text: "", level: 0))
} else {
out.append(Line(kind: .text, text: line, level: 0))
}
}
return out
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(lines) { line in
switch line.kind {
case .code:
Text(line.text)
.font(.caption.monospaced())
.frame(maxWidth: .infinity, alignment: .leading)
.padding(4)
.background(Color(.secondarySystemBackground))
case .heading:
Text(line.text)
.font(line.level == 1 ? .title2.bold() : line.level == 2 ? .title3.bold() : .headline)
.frame(maxWidth: .infinity, alignment: .leading)
case .bullet:
HStack(alignment: .top, spacing: 6) {
Text("•")
inline(line.text)
}
.frame(maxWidth: .infinity, alignment: .leading)
case .blank:
Spacer(minLength: 4)
case .text:
inline(line.text)
}
}
}
.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() != ""
}
}