Files
ios-gitea-prs/GiteaPR/Detail/Tabs.swift
T
Hermes 9f3fb95cbe
Build iOS App / Build .ipa and publish source (push) Successful in 21s
Fix commits tab + add CI job logs; bump to 1.7
Commits tab was empty: Gitea PR commits have no 'id' field (only sha), but
PRCommit.id was a non-optional let decoded from 'id', so decoding [PRCommit]
always threw and the tab silently showed nothing. Make PRCommit.id a computed
sha-based identity so the array decodes and renders.

CI: add a Logs button per job that fetches the job's raw log text
(/actions/jobs/{id}/logs) into a viewable, shareable screen. Bump to 1.7.
2026-08-12 21:55:24 +00:00

267 lines
8.6 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: - 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)
}
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"
}
}