Build iOS App / Build .ipa and publish source (push) Failing after 16s
The horizontal paging ScrollView squished the markdown text and the TabView broke navigation. Revert to the plain vertical ScrollView + switch (always rendered correctly). Swipe-to-change now lives on the bottom tab bar itself (simultaneous DragGesture), so tapping tabs still works. Rewrite MarkdownText line-based: each source line renders on its own line (AttributedString collapses single newlines into spaces), with heading/bullet/code handling. Bump to 3.3.
276 lines
10 KiB
Swift
276 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
struct PRDetailView: View {
|
|
@StateObject private var vm: PRDetailViewModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var selectedTab: DetailTab? = .overview
|
|
@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
|
|
@State private var showMergeSuccess = false
|
|
|
|
enum DetailTab: String, CaseIterable, Identifiable, Hashable {
|
|
case overview = "Overview"
|
|
case conversation = "Conversation"
|
|
case files = "Files"
|
|
case checks = "Checks"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .overview: return "doc.text"
|
|
case .conversation: return "bubble.left.and.bubble.right"
|
|
case .files: return "doc.plaintext"
|
|
case .checks: return "checkmark.circle"
|
|
}
|
|
}
|
|
}
|
|
|
|
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 } }
|
|
.overlay { if showMergeSuccess { mergeSuccessOverlay } }
|
|
.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) {
|
|
// Merge succeeded: show the success animation, then pop back
|
|
// to the landing page.
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
|
|
showMergeSuccess = true
|
|
}
|
|
Task {
|
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
|
|
// Vertical scroll — has always rendered correctly (a horizontal
|
|
// ScrollView/TabView broke NavigationLink and mangled markdown).
|
|
ScrollView {
|
|
switch selectedTab {
|
|
case .overview: OverviewTab(pr: pr)
|
|
case .conversation: ConversationTab(vm: vm)
|
|
case .files: FilesTab(vm: vm)
|
|
case .checks: ChecksTab(vm: vm, onRerun: { showRerunConfirm = $0 })
|
|
case .none: OverviewTab(pr: pr)
|
|
}
|
|
}
|
|
|
|
bottomTabBar
|
|
}
|
|
.toolbar {
|
|
if prIsOpen {
|
|
ToolbarItem(placement: .primaryAction) { mergeButton }
|
|
ToolbarItem(placement: .primaryAction) {
|
|
// Disable while a review submission is in flight so rapid
|
|
// taps can't stack duplicate reviews.
|
|
reviewMenu.disabled(vm.busyMessage != nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.secondarySystemBackground))
|
|
}
|
|
|
|
/// Bottom tab bar: a floating, frosted-glass pill inset from the screen
|
|
/// edges (like the SideStore tab bar), with a highlighted capsule behind
|
|
/// the active tab. Icon + text per tab.
|
|
private var bottomTabBar: some View {
|
|
HStack(spacing: 4) {
|
|
ForEach(DetailTab.allCases) { tab in
|
|
Button {
|
|
selectedTab = tab
|
|
} label: {
|
|
VStack(spacing: 2) {
|
|
Image(systemName: tab.icon)
|
|
.font(.system(size: 20, weight: .medium))
|
|
Text(tab.rawValue)
|
|
.font(.caption2.weight(.medium))
|
|
}
|
|
.foregroundColor(selectedTab == tab ? .accentColor : .secondary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
selectedTab == tab
|
|
? AnyShapeStyle(.ultraThinMaterial)
|
|
: AnyShapeStyle(.clear)
|
|
)
|
|
.clipShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
Capsule()
|
|
.fill(.regularMaterial)
|
|
.shadow(color: .black.opacity(0.15), radius: 10, y: 4)
|
|
)
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 8)
|
|
// Swipe across the bar to change sections (iOS 26 feel). Taps on the
|
|
// individual tab buttons still work — simultaneous gesture.
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 20)
|
|
.onEnded { value in
|
|
let tabs = DetailTab.allCases
|
|
guard let current = selectedTab ?? .overview,
|
|
let idx = tabs.firstIndex(of: current) else { return }
|
|
if value.translation.width < -40, idx < tabs.count - 1 {
|
|
withAnimation { selectedTab = tabs[idx + 1] }
|
|
} else if value.translation.width > 40, idx > 0 {
|
|
withAnimation { selectedTab = tabs[idx - 1] }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private var reviewMenu: some View {
|
|
Menu {
|
|
Button("Comment") { requestChangesMode = false; showCommentSheet = true }
|
|
Button("Approve") { Task { await vm.submitReview(state: "approved", comment: nil) } }
|
|
Button("Request changes") { requestChangesMode = true; showCommentSheet = true }
|
|
Divider()
|
|
if prIsOpen {
|
|
Button("Close", role: .destructive) { showCloseConfirm = true }
|
|
} else {
|
|
Button("Reopen") { Task { await vm.setState("open") } }
|
|
}
|
|
} label: {
|
|
Image(systemName: "checkmark.bubble")
|
|
}
|
|
}
|
|
|
|
private var mergeButton: some View {
|
|
Button {
|
|
showMergeSheet = true
|
|
} label: {
|
|
Image(systemName: "arrow.triangle.merge")
|
|
}
|
|
.disabled(vm.ciStatus == .failure &&
|
|
SettingsStore.shared.mergeGateMode == .block)
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/// Full-screen success animation shown briefly after a merge, then the
|
|
/// view pops back to the landing page.
|
|
private var mergeSuccessOverlay: some View {
|
|
ZStack {
|
|
Color.black.opacity(0.4)
|
|
.ignoresSafeArea()
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 72))
|
|
.foregroundColor(.green)
|
|
.scaleEffect(showMergeSuccess ? 1 : 0.3)
|
|
.opacity(showMergeSuccess ? 1 : 0)
|
|
Text("Merged")
|
|
.font(.title2.weight(.semibold))
|
|
.foregroundColor(.white)
|
|
}
|
|
}
|
|
.transition(.opacity)
|
|
}
|
|
}
|