GiteaPR: native iOS app to manage Gitea pull requests
Build iOS App / Build .ipa and publish source (push) Failing after 35s
Build iOS App / Build .ipa and publish source (push) Failing after 35s
Multi-repo PR inbox, review (approve/request changes/comment), merge with gating toggle, read-only diff viewer, and Gitea Actions check status with one-tap re-run. Ships unsigned via the iMac runner to the SideStore source.
This commit is contained in:
commit
1025634978
27 files changed
+2322
No files matched your search
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Read-only unified diff viewer for a single changed file.
|
||||
///
|
||||
/// Parses a Gitea unified diff patch into line hunks so added/deleted lines can
|
||||
/// be colour-coded. Gitea only returns `patch` for small diffs; large files
|
||||
/// fall back to a raw text view.
|
||||
struct DiffView: View {
|
||||
let file: ChangedFile
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let patch = file.patch, !patch.isEmpty {
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(parse(patch)) { line in
|
||||
diffLine(line)
|
||||
}
|
||||
}
|
||||
.font(.caption.monospaced())
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Diff too large to preview",
|
||||
systemImage: "doc.text",
|
||||
description: Text("Gitea didn't return a patch for this file."))
|
||||
}
|
||||
}
|
||||
.navigationTitle(file.filename)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func diffLine(_ line: DiffLine) -> some View {
|
||||
Text(line.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 1)
|
||||
.background(background(for: line.type))
|
||||
.foregroundColor(foreground(for: line.type))
|
||||
}
|
||||
|
||||
private func background(for type: DiffLineType) -> Color {
|
||||
switch type {
|
||||
case .addition: return Color.green.opacity(0.15)
|
||||
case .deletion: return Color.red.opacity(0.15)
|
||||
case .hunk, .meta: return Color(.secondarySystemBackground)
|
||||
case .context: return .clear
|
||||
}
|
||||
}
|
||||
private func foreground(for type: DiffLineType) -> Color {
|
||||
switch type {
|
||||
case .addition: return .green
|
||||
case .deletion: return .red
|
||||
case .hunk: return .secondary
|
||||
case .meta, .context: return .primary
|
||||
}
|
||||
}
|
||||
|
||||
private func parse(_ patch: String) -> [DiffLine] {
|
||||
patch.components(separatedBy: "\n").map { line in
|
||||
if line.hasPrefix("@@") { return DiffLine(text: line, type: .hunk) }
|
||||
if line.hasPrefix("diff ") || line.hasPrefix("index ")
|
||||
|| line.hasPrefix("--- ") || line.hasPrefix("+++ ") {
|
||||
return DiffLine(text: line, type: .meta)
|
||||
}
|
||||
if line.hasPrefix("+") { return DiffLine(text: line, type: .addition) }
|
||||
if line.hasPrefix("-") { return DiffLine(text: line, type: .deletion) }
|
||||
return DiffLine(text: line, type: .context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum DiffLineType { case context, addition, deletion, hunk, meta }
|
||||
|
||||
private struct DiffLine: Identifiable {
|
||||
let text: String
|
||||
let type: DiffLineType
|
||||
var id: UUID { UUID() }
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PRDetailView: View {
|
||||
@StateObject private var vm: PRDetailViewModel
|
||||
@State private var selectedTab: DetailTab = .conversation
|
||||
@State private var showActionSheet = false
|
||||
@State private var showCommentSheet = false
|
||||
@State private var showMergeSheet = false
|
||||
@State private var showCloseConfirm = false
|
||||
@State private var showRerunConfirm: ActionRun?
|
||||
@State private var requestChangesMode = false
|
||||
|
||||
enum DetailTab: String, CaseIterable, Identifiable {
|
||||
case conversation = "Conversation"
|
||||
case files = "Files"
|
||||
case commits = "Commits"
|
||||
case checks = "Checks"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
init(repo: Repository, prNumber: Int) {
|
||||
_vm = StateObject(wrappedValue: PRDetailViewModel(repo: repo,
|
||||
prNumber: prNumber))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let pr = vm.pr {
|
||||
content(pr)
|
||||
} else if vm.isLoading {
|
||||
ProgressView("Loading…")
|
||||
} else if let err = vm.errorMessage {
|
||||
ContentUnavailableView("Couldn't load PR",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(err))
|
||||
.toolbar { ToolbarItem(placement: .primaryAction) { reloadButton } }
|
||||
} else {
|
||||
ProgressView("Loading…")
|
||||
}
|
||||
}
|
||||
.navigationTitle("#\(vm.prNumber)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await vm.load() }
|
||||
.alert("Error", isPresented: .init(
|
||||
get: { vm.errorMessage != nil },
|
||||
set: { if !$0 { vm.errorMessage = nil } })) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: { Text(vm.errorMessage ?? "") }
|
||||
.overlay { if vm.busyMessage != nil { busyOverlay } }
|
||||
.confirmationDialog("Actions", isPresented: $showActionSheet, titleVisibility: .visible) {
|
||||
Button("Comment") { showCommentSheet = true }
|
||||
Button("Approve") { Task { await vm.submitReview(state: "approved", comment: nil) } }
|
||||
Button("Request changes") { requestChangesMode = true; showCommentSheet = true }
|
||||
Button("Merge") { showMergeSheet = true }
|
||||
if prIsOpen {
|
||||
Button("Close", role: .destructive) { showCloseConfirm = true }
|
||||
} else {
|
||||
Button("Reopen") { Task { await vm.setState("open") } }
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Close this PR?", isPresented: $showCloseConfirm, titleVisibility: .visible) {
|
||||
Button("Close", role: .destructive) { Task { await vm.setState("closed") } }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { Text("This will close the pull request.") }
|
||||
.sheet(isPresented: $showCommentSheet) {
|
||||
CommentSheet(vm: vm,
|
||||
mode: requestChangesMode ? .requestChanges : .comment)
|
||||
.onDisappear { requestChangesMode = false }
|
||||
}
|
||||
.sheet(isPresented: $showMergeSheet) { MergeSheet(vm: vm) }
|
||||
.confirmationDialog("Re-run workflow?",
|
||||
isPresented: .init(
|
||||
get: { showRerunConfirm != nil },
|
||||
set: { if !$0 { showRerunConfirm = nil } }),
|
||||
titleVisibility: .visible) {
|
||||
Button("Re-run") {
|
||||
if let run = showRerunConfirm {
|
||||
Task { await vm.rerun(runID: run.id) }
|
||||
}
|
||||
showRerunConfirm = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Re-run \"\(showRerunConfirm?.name ?? "workflow")\"?")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(_ pr: PullRequest) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
header(pr)
|
||||
Picker("Tab", selection: $selectedTab) {
|
||||
ForEach(DetailTab.allCases) { t in Text(t.rawValue).tag(t) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
ScrollView {
|
||||
switch selectedTab {
|
||||
case .conversation: ConversationTab(vm: vm)
|
||||
case .files: FilesTab(vm: vm)
|
||||
case .commits: CommitsTab(commits: vm.commits)
|
||||
case .checks: ChecksTab(vm: vm, onRerun: { showRerunConfirm = $0 })
|
||||
}
|
||||
}
|
||||
|
||||
if prIsOpen {
|
||||
actionBar(pr)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) { reloadButton }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func header(_ pr: PullRequest) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(pr.title)
|
||||
.font(.title3.weight(.bold))
|
||||
HStack(spacing: 6) {
|
||||
Text("\(vm.repo.fullName)#\(pr.number)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
CICircle(status: vm.ciStatus)
|
||||
if vm.reviewState != .unknown {
|
||||
ReviewBadge(state: vm.reviewState)
|
||||
}
|
||||
if pr.draft == true {
|
||||
Text("Draft").font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if let body = pr.body, !body.isEmpty {
|
||||
Text(body)
|
||||
.font(.callout)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func actionBar(_ pr: PullRequest) -> some View {
|
||||
HStack {
|
||||
Button { showActionSheet = true } label: {
|
||||
Label("Review", systemImage: "checkmark.bubble")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Merge") { showMergeSheet = true }
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(vm.ciStatus == .failure &&
|
||||
SettingsStore.shared.mergeGateMode == .block)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var prIsOpen: Bool {
|
||||
(vm.pr?.state.lowercased() ?? "") == "open"
|
||||
}
|
||||
|
||||
private var reloadButton: some View {
|
||||
Button { Task { await vm.load() } } label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
|
||||
private var busyOverlay: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.15)
|
||||
ProgressView(vm.busyMessage ?? "")
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class PRDetailViewModel: ObservableObject {
|
||||
let repo: Repository
|
||||
let prNumber: Int
|
||||
|
||||
@Published var pr: PullRequest?
|
||||
@Published var comments: [IssueComment] = []
|
||||
@Published var reviews: [PullReview] = []
|
||||
@Published var files: [ChangedFile] = []
|
||||
@Published var commits: [PRCommit] = []
|
||||
@Published var runs: [ActionRun] = []
|
||||
@Published var jobs: [Int64: [ActionJob]] = [:]
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var isActionsLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var busyMessage: String?
|
||||
|
||||
private var owner: String { repo.owner.login }
|
||||
|
||||
init(repo: Repository, prNumber: Int) {
|
||||
self.repo = repo
|
||||
self.prNumber = prNumber
|
||||
}
|
||||
|
||||
var ciStatus: CIStatus { CIStatus.from(runs: runs) }
|
||||
var reviewState: ReviewState { ReviewState.from(reviews: reviews) }
|
||||
|
||||
var hasFailedRun: Bool {
|
||||
runs.contains {
|
||||
let c = ($0.conclusion ?? "").lowercased()
|
||||
return c == "failure" || c == "cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
func load() async {
|
||||
guard let client = SettingsStore.shared.client() else { return }
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
async let prT = client.pullRequest(owner: owner, repo: repo.name, number: prNumber)
|
||||
async let commentsT = client.comments(owner: owner, repo: repo.name, number: prNumber)
|
||||
async let reviewsT = client.reviews(owner: owner, repo: repo.name, number: prNumber)
|
||||
async let filesT = client.changedFiles(owner: owner, repo: repo.name, number: prNumber)
|
||||
async let commitsT = client.commits(owner: owner, repo: repo.name, number: prNumber)
|
||||
pr = try await prT
|
||||
comments = try await commentsT
|
||||
reviews = try await reviewsT
|
||||
files = try await filesT
|
||||
commits = try await commitsT
|
||||
// CI runs depend on the head SHA from the PR.
|
||||
if let sha = pr?.head?.sha, !sha.isEmpty {
|
||||
await loadRuns(client: client, sha: sha)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRuns(client: GiteaClient, sha: String) async {
|
||||
isActionsLoading = true
|
||||
defer { isActionsLoading = false }
|
||||
do {
|
||||
runs = try await client.runs(owner: owner, repo: repo.name, sha: sha)
|
||||
// Load jobs for each run concurrently.
|
||||
for run in runs {
|
||||
let jobList = (try? await client.jobs(owner: owner, repo: repo.name,
|
||||
runID: run.id)) ?? []
|
||||
jobs[run.id] = jobList
|
||||
}
|
||||
} catch {
|
||||
// CI is best-effort; don't fail the whole detail view.
|
||||
}
|
||||
}
|
||||
|
||||
func rerun(runID: Int64) async {
|
||||
guard let client = SettingsStore.shared.client() else { return }
|
||||
busyMessage = "Re-running job…"
|
||||
defer { busyMessage = nil }
|
||||
do {
|
||||
try await client.rerun(owner: owner, repo: repo.name, runID: runID)
|
||||
if let sha = pr?.head?.sha {
|
||||
await loadRuns(client: client, sha: sha)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func postComment(_ body: String) async {
|
||||
guard let client = SettingsStore.shared.client() else { return }
|
||||
busyMessage = "Posting…"
|
||||
defer { busyMessage = nil }
|
||||
do {
|
||||
let c = try await client.postComment(owner: owner, repo: repo.name,
|
||||
number: prNumber, body: body)
|
||||
comments.append(c)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func submitReview(state: String, comment: String?) async {
|
||||
guard let client = SettingsStore.shared.client() else { return }
|
||||
busyMessage = "Submitting…"
|
||||
defer { busyMessage = nil }
|
||||
do {
|
||||
let r = try await client.submitReview(owner: owner, repo: repo.name,
|
||||
number: prNumber, state: state,
|
||||
comment: comment)
|
||||
reviews.append(r)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func merge(method: String) async -> Bool {
|
||||
guard let client = SettingsStore.shared.client() else { return false }
|
||||
busyMessage = "Merging…"
|
||||
defer { busyMessage = nil }
|
||||
do {
|
||||
try await client.merge(owner: owner, repo: repo.name,
|
||||
number: prNumber, method: method)
|
||||
// Refresh the PR state to reflect the merge.
|
||||
pr = try? await client.pullRequest(owner: owner, repo: repo.name,
|
||||
number: prNumber)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setState(_ state: String) async {
|
||||
guard let client = SettingsStore.shared.client() else { return }
|
||||
busyMessage = "Updating…"
|
||||
defer { busyMessage = nil }
|
||||
do {
|
||||
try await client.setState(owner: owner, repo: repo.name,
|
||||
number: prNumber, state: state)
|
||||
pr = try? await client.pullRequest(owner: owner, repo: repo.name,
|
||||
number: prNumber)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Composes a comment. `mode` picks whether it posts a plain comment or a
|
||||
/// review with a state (Approve / Request changes / Comment).
|
||||
struct CommentSheet: View {
|
||||
@ObservedObject var vm: PRDetailViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var text = ""
|
||||
var mode: CommentMode = .comment
|
||||
|
||||
enum CommentMode { case comment, approve, requestChanges }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(title).font(.headline)
|
||||
TextEditor(text: $text)
|
||||
.frame(minHeight: 150)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.separator)))
|
||||
Text("\(text.count) characters")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Submit") { submit() }
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch mode {
|
||||
case .approve: return "Approve"
|
||||
case .requestChanges: return "Request changes"
|
||||
case .comment: return "Comment"
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
let body = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch mode {
|
||||
case .approve:
|
||||
Task { await vm.submitReview(state: "approved", comment: body) }
|
||||
case .requestChanges:
|
||||
Task { await vm.submitReview(state: "changes", comment: body) }
|
||||
case .comment:
|
||||
Task { await vm.postComment(body) }
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct MergeSheet: View {
|
||||
@ObservedObject var vm: PRDetailViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var method = "merge"
|
||||
|
||||
private let methods = [("merge", "Create merge commit"),
|
||||
("rebase", "Rebase and merge"),
|
||||
("squash", "Squash and merge")]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Merge method") {
|
||||
Picker("Method", selection: $method) {
|
||||
ForEach(methods, id: \.0) { m in
|
||||
Text(m.1).tag(m.0)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
}
|
||||
Section {
|
||||
HStack {
|
||||
Text("CI")
|
||||
Spacer()
|
||||
switch vm.ciStatus {
|
||||
case .success: Label("Passing", systemImage: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
case .failure: Label("Failing", systemImage: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
case .running: Label("Running", systemImage: "hourglass")
|
||||
.foregroundColor(.orange)
|
||||
case .cancelled, .unknown: Label("Unknown", systemImage: "questionmark.circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if vm.ciStatus == .failure {
|
||||
if SettingsStore.shared.mergeGateMode == .block {
|
||||
Label("Merging is blocked while CI is red (Settings).",
|
||||
systemImage: "lock")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Label("CI is red — you can still merge (Settings allows it).",
|
||||
systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Merge")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Merge") { performMerge() }
|
||||
.disabled(SettingsStore.shared.mergeGateMode == .block
|
||||
&& vm.ciStatus == .failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performMerge() {
|
||||
Task {
|
||||
let success = await vm.merge(method: method)
|
||||
if success { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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(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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user