Build iOS App / Build .ipa and publish source (push) Successful in 34s
- After a merge, the app returns to the landing page but the inbox kept stale PRs. Add an inboxNeedsRefresh flag set on merge success; InboxView reloads on next appearance when it's set. - The PR content (incl. the Checks/workflow tab) is now pull-to-refreshable via .refreshable on the content ScrollView, so workflow status can be updated by pulling down. Bump to 3.4.
278 lines
10 KiB
Swift
278 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: mark the inbox for refresh, show the success
|
|
// animation, then pop back to the landing page.
|
|
SettingsStore.shared.inboxNeedsRefresh = true
|
|
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)
|
|
}
|
|
}
|
|
.refreshable { await vm.load() }
|
|
|
|
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
|
|
let current = selectedTab ?? .overview
|
|
guard 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)
|
|
}
|
|
}
|