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() != "" } }