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) } }