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 { Label(error, systemImage: "hand.raised") .foregroundStyle(.orange) .font(.subheadline) } } if let body = mr.body, !body.isEmpty { Section { MarkdownView(markdown: body) .padding(.vertical, 4) } } 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(.headline) HStack(spacing: 6) { MRStateBadge(state: mr.state) Text(mr.source.isEmpty ? "(source gone)" : mr.source) .lineLimit(1) Image(systemName: "arrow.right") .font(.caption2) Text(mr.targetRef) } .font(.caption) .foregroundStyle(.secondary) HStack(spacing: 6) { Text("by \(mr.author)") Text(mr.createdAt, format: .relative(presentation: .named)) .foregroundStyle(.tertiary) } .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 2) } } 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(.green) Text("−\(diff.deletions)") .foregroundStyle(.red) } } .font(.subheadline) } } } private func commitsSection(_ commits: [MRDetail.MRCommit]) -> some View { Section("Commits") { ForEach(commits) { commit in HStack(spacing: 8) { Text(commit.shortSHA) .font(.caption.monospaced()) .foregroundStyle(.secondary) Text(commit.subject) .font(.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(.subheadline) Spacer() Text(check.state) .font(.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" ? .green : .orange) Text(review.reviewer) .font(.subheadline) Spacer() if review.stale { Text("stale") .font(.caption2) .padding(.horizontal, 5) .padding(.vertical, 1) .background(.quaternary, in: Capsule()) } } } } } private var threadsSection: some View { Section("Threads — \(model.unresolvedCount) unresolved") { ForEach(model.threads) { thread in ThreadView(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(.caption.weight(.semibold)) Text(comment.createdAt, format: .relative(presentation: .named)) .font(.caption) .foregroundStyle(.tertiary) } MarkdownView(markdown: comment.body) .font(.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": .green case "failure", "error": .red case "pending", "running": .orange default: .secondary } } } /// One review thread: anchor, comments, reply, resolve. private struct ThreadView: 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(.caption) .foregroundStyle(thread.isResolved ? .green : .orange) Text("\(thread.path):\(thread.line)") .font(.caption.monospaced()) .lineLimit(1) if thread.stale { Text("stale") .font(.caption2) .padding(.horizontal, 5) .padding(.vertical, 1) .background(.quaternary, in: Capsule()) } Spacer() Button(thread.isResolved ? "Reopen" : "Resolve") { Task { await model.setResolved(thread, !thread.isResolved) } } .font(.caption) .buttonStyle(.bordered) .disabled(model.working) } ForEach(thread.comments) { comment in VStack(alignment: .leading, spacing: 2) { HStack { Text(comment.author) .font(.caption.weight(.semibold)) Text(comment.createdAt, format: .relative(presentation: .named)) .font(.caption2) .foregroundStyle(.tertiary) } Text(comment.body) .font(.subheadline) } } if !thread.isResolved { HStack { TextField("Reply", text: $replyText, axis: .vertical) .font(.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) } }