Files
ios-gitea-prs/GiteaPR/Detail/Tabs.swift
T
Hermes fce0385708
Build iOS App / Build .ipa and publish source (push) Successful in 21s
Restructure PR detail: remove Commits tab, bottom icon tab bar, toolbar actions, markdown overview; bump to 1.9
- 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.
2026-08-12 22:28:18 +00:00

295 lines
9.3 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.
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() != ""
}
}