import SwiftUI struct MRView: View { @State private var model: MRDetailViewModel @State private var commentText = "" @State private var confirmingMerge = false @State private var confirmingClose = false @State private var editing = false @State private var draftTitle = "" @State private var draftBody = "" init(client: GitbayClient, repo: String, number: Int64) { _model = State(initialValue: MRDetailViewModel( client: client, repoPath: repo, number: number )) } var body: some View { List { if let mr = model.state.value { header(mr) if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } if let body = mr.body, !body.isEmpty { Section { MarkdownView(markdown: body) .padding(.vertical, 4) } } milestoneSection(mr) diffSection if let commits = mr.commits, !commits.isEmpty { commitsSection(commits) } if let checks = mr.checks, !checks.isEmpty { checksSection(checks, combined: mr.checksCombined) } if let reviews = mr.reviews, !reviews.isEmpty { reviewsSection(reviews) } if !model.threads.isEmpty { threadsSection } commentsSection(mr.comments ?? []) } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("!\(model.number)") .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .task { await model.load() } .refreshable { await model.load() } .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) { Button("Merge") { Task { await model.merge() } } Button("Squash") { Task { await model.merge(strategy: "squash") } } Button("Fast-forward") { Task { await model.merge(strategy: "ff") } } Button("Rebase") { Task { await model.merge(strategy: "rebase") } } Button("Cancel", role: .cancel) {} } message: { Text("The server enforces approvals, threads and checks — a refusal will say why.") } .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) { Button("Close", role: .destructive) { Task { await model.close() } } Button("Cancel", role: .cancel) {} } .sheet(isPresented: $editing) { ComposeSheet( heading: "Edit !\(model.number)", submitLabel: "Save", working: model.working, errorMessage: model.actionError, title: $draftTitle, bodyText: $draftBody ) { Task { await model.edit(title: draftTitle, body: draftBody) if model.actionError == nil { editing = false } } } } } // MARK: - Sections @ViewBuilder private func header(_ mr: MRDetail) -> some View { Section { VStack(alignment: .leading, spacing: 6) { Text(mr.title) .font(.gbSans(.headline)) HStack(spacing: 6) { MRStateBadge(state: mr.state) Text(mr.source.isEmpty ? "(source gone)" : mr.source) .lineLimit(1) Image(systemName: "arrow.right") .font(.gbSans(.caption2)) Text(mr.targetRef) } .font(.gbSans(.caption)) .foregroundStyle(.secondary) HStack(spacing: 6) { Text("by \(mr.author)") Text(mr.createdAt, format: .relative(presentation: .named)) .foregroundStyle(.tertiary) } .font(.gbSans(.caption)) .foregroundStyle(.secondary) } .padding(.vertical, 2) } } private func milestoneSection(_ mr: MRDetail) -> some View { Section("Milestone") { Menu { Button("None") { Task { await model.setMilestone(nil) } } ForEach(model.availableMilestones ?? []) { milestone in Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") { Task { await model.setMilestone(milestone.title) } } } } label: { HStack { Label(mr.milestone ?? "None", systemImage: "flag") .font(.gbSans(.subheadline)) Spacer() Image(systemName: "chevron.up.chevron.down") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } .disabled(model.working) .task { await model.loadMilestones() } .accessibilityIdentifier("mr-milestone-menu") } } private var diffSection: some View { Section { NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) { HStack { Label("Diff", systemImage: "plus.forwardslash.minus") Spacer() if let diff = model.diff { Text("+\(diff.additions)") .foregroundStyle(Color.gbOK) Text("−\(diff.deletions)") .foregroundStyle(Color.gbBad) } } .font(.gbSans(.subheadline)) } } } private func commitsSection(_ commits: [MRDetail.MRCommit]) -> some View { Section("Commits") { ForEach(commits) { commit in HStack(spacing: 8) { Text(commit.shortSHA) .font(.gbMono(.caption)) .foregroundStyle(.secondary) Text(commit.subject) .font(.gbSans(.subheadline)) .lineLimit(1) } } } } private func checksSection(_ checks: [MRDetail.Check], combined: String?) -> some View { Section("Checks" + (combined.map { " — \($0)" } ?? "")) { ForEach(checks, id: \.context) { check in HStack { Image(systemName: checkIcon(check.state)) .foregroundStyle(checkColor(check.state)) Text(check.context) .font(.gbSans(.subheadline)) Spacer() Text(check.state) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } } } } private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View { Section("Reviews") { ForEach(reviews) { review in HStack { Image(systemName: review.verdict == "approve" ? "checkmark.circle.fill" : "exclamationmark.circle.fill") .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn) Text(review.reviewer) .font(.gbSans(.subheadline)) Spacer() if review.stale { GBChip("stale", .gbWarn) } } } } } private var threadsSection: some View { Section("Threads — \(model.unresolvedCount) unresolved") { ForEach(model.threads) { thread in ReviewThreadView(thread: thread, model: model) } } } private func commentsSection(_ comments: [MRDetail.MRComment]) -> some View { Section("Comments") { ForEach(comments) { comment in VStack(alignment: .leading, spacing: 4) { HStack { Text(comment.author) .font(.gbSans(.caption).weight(.semibold)) Text(comment.createdAt, format: .relative(presentation: .named)) .font(.gbSans(.caption)) .foregroundStyle(.tertiary) } MarkdownView(markdown: comment.body) .font(.gbSans(.subheadline)) } .padding(.vertical, 2) } HStack { TextField("Comment", text: $commentText, axis: .vertical) .lineLimit(1...5) Button { let text = commentText commentText = "" Task { await model.comment(text) } } label: { Image(systemName: "arrow.up.circle.fill") } .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.working) } } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { if let mr = model.state.value, mr.isOpen { Menu { Button { draftTitle = mr.title draftBody = mr.body ?? "" editing = true } label: { Label("Edit", systemImage: "pencil") } Divider() Button { Task { await model.review(.approve) } } label: { Label("Approve", systemImage: "checkmark.circle") } Button { Task { await model.review(.requestChanges) } } label: { Label("Request Changes", systemImage: "exclamationmark.circle") } Divider() Button { confirmingMerge = true } label: { Label("Merge", systemImage: "arrow.triangle.merge") } Button(role: .destructive) { confirmingClose = true } label: { Label("Close", systemImage: "xmark.circle") } } label: { if model.working { ProgressView() } else { Image(systemName: "ellipsis.circle") } } .disabled(model.working) .accessibilityIdentifier("mr-actions-menu") } } } private func checkIcon(_ state: String) -> String { switch state { case "success": "checkmark.circle.fill" case "failure", "error": "xmark.circle.fill" case "pending", "running": "circle.dotted" default: "questionmark.circle" } } private func checkColor(_ state: String) -> Color { switch state { case "success": .gbOK case "failure", "error": .gbBad case "pending", "running": .gbWarn default: .secondary } } } /// One review thread: anchor, comments, reply, resolve. struct ReviewThreadView: View { let thread: ReviewThread let model: MRDetailViewModel @State private var replyText = "" var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Image(systemName: thread.isResolved ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right") .font(.gbSans(.caption)) .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn) Text("\(thread.path):\(thread.line)") .font(.gbMono(.caption)) .lineLimit(1) if thread.stale { GBChip("stale", .gbWarn) } Spacer() Button(thread.isResolved ? "Reopen" : "Resolve") { Task { await model.setResolved(thread, !thread.isResolved) } } .font(.gbSans(.caption)) .buttonStyle(.bordered) .disabled(model.working) } ForEach(thread.comments) { comment in VStack(alignment: .leading, spacing: 2) { HStack { Text(comment.author) .font(.gbSans(.caption).weight(.semibold)) Text(comment.createdAt, format: .relative(presentation: .named)) .font(.gbSans(.caption2)) .foregroundStyle(.tertiary) } Text(comment.body) .font(.gbSans(.subheadline)) } } if !thread.isResolved { HStack { TextField("Reply", text: $replyText, axis: .vertical) .font(.gbSans(.subheadline)) .lineLimit(1...4) Button { let text = replyText replyText = "" Task { await model.reply(to: thread, text) } } label: { Image(systemName: "arrow.up.circle.fill") } .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.working) } } } .padding(.vertical, 4) } }