Build iOS App / Build .ipa and publish source (push) Successful in 25s
- 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.
369 lines
12 KiB
Swift
369 lines
12 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
|
||
}
|
||
|
||
/// 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() != ""
|
||
}
|
||
}
|