Build iOS App / Build .ipa and publish source (push) Successful in 21s
This Gitea instance never returns a 'patch' field from /pulls/{n}/files (the
field is absent for all files, regardless of size), so the diff viewer always
fell back to 'Diff too large to preview'. Now DiffView fetches the PR's .diff
endpoint (plain-text unified diff), extracts the hunks for the selected file,
and renders them colour-coded. Bump to 1.4.
259 lines
8.2 KiB
Swift
259 lines
8.2 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: - Commits
|
||
|
||
struct CommitsTab: View {
|
||
let commits: [PRCommit]
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
ForEach(commits) { commit in
|
||
HStack(alignment: .top, spacing: 10) {
|
||
Text(commit.sha ?? commit.id)
|
||
.font(.caption2.monospaced())
|
||
.foregroundColor(.secondary)
|
||
.frame(width: 60, alignment: .leading)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(commit.commit?.message ?? "(no message)")
|
||
.font(.callout)
|
||
.lineLimit(3)
|
||
Text(commit.author?.name ?? "")
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
Divider()
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.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"
|
||
}
|
||
}
|