Build iOS App / Build .ipa and publish source (push) Successful in 21s
- Remove the Commits tab (not useful). - Move the section switcher to a bottom tab bar with icons (Overview / Conversation / Files / Checks) instead of a top segmented control. - Move Review (menu) and Merge into the navigation toolbar, removing the bottom action bar. - Add an Overview tab that renders the PR body as markdown via SwiftUI's built-in AttributedString(markdown:) (no third-party dependency). - Bump to 1.9.
295 lines
9.3 KiB
Swift
295 lines
9.3 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.
|
||
struct MarkdownText: View {
|
||
let markdown: String
|
||
|
||
init(_ markdown: String) {
|
||
self.markdown = markdown
|
||
}
|
||
|
||
var body: some View {
|
||
if let attributed = try? AttributedString(markdown: markdown) {
|
||
Text(attributed)
|
||
.font(.callout)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
} else {
|
||
Text(markdown)
|
||
.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() != ""
|
||
}
|
||
}
|