import SwiftUI struct MRView: View { @State private var model: MRDetailViewModel @State private var commentText = "" @State private var commentFormat = "md" @State private var confirmingMerge = false @State private var confirmingClose = false @State private var editing = false @State private var draftTitle = "" @State private var draftBody = "" @State private var draftFormat = "md" @State private var editingReviewer = "" @State private var retargeting = false @State private var pendingRetarget: String? 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 { FormattedBodyView(text: body, format: mr.bodyFormat) .padding(.vertical, 4) } } milestoneSection(mr) stackSection(mr) reviewersSection(mr) diffSection if !model.revisions.isEmpty { revisionsSection } 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) { if let mr = model.state.value, !mr.stacked.isEmpty { Button("Merge") { Task { await model.merge() } } Button("Fast-forward") { Task { await model.merge(strategy: "ff") } } } else { 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: { if let mr = model.state.value, !mr.stacked.isEmpty { Text("Merge requests are stacked on this one. Squash and rebase are unavailable — they would rewrite the commits the stack builds on.") } else { 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) {} } .confirmationDialog( "Retarget to \(pendingRetarget ?? "")?", isPresented: .init( get: { pendingRetarget != nil }, set: { if !$0 { pendingRetarget = nil } } ), titleVisibility: .visible ) { Button("Retarget") { guard let branch = pendingRetarget else { return } pendingRetarget = nil Task { await model.retarget(to: branch) } } Button("Cancel", role: .cancel) { pendingRetarget = nil } } message: { Text("Existing approvals were of the diff against the old target and will no longer apply.") } .sheet(isPresented: $retargeting) { RetargetSheet(model: model) { branch in retargeting = false pendingRetarget = branch } } .sheet(isPresented: $editing) { ComposeSheet( heading: "Edit !\(model.number)", submitLabel: "Save", working: model.working, errorMessage: model.actionError, title: $draftTitle, bodyText: $draftBody, format: $draftFormat ) { Task { await model.edit(title: draftTitle, body: draftBody, format: draftFormat) 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) if mr.draft { GBChip("draft", .secondary) } 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) resolution(mr) } .padding(.vertical, 2) } } /// What became of the merge request, once it is no longer open. The /// actor is absent for imported rows, which carry only a time. @ViewBuilder private func resolution(_ mr: MRDetail) -> some View { if let at = mr.mergedAt { stampLine("merged", by: mr.mergedBy, at: at) } else if let at = mr.closedAt { stampLine("closed without merging", by: mr.closedBy, at: at) } } private func stampLine(_ verb: String, by actor: String?, at: Date) -> some View { HStack(spacing: 6) { Text(actor.map { "\(verb) by \($0)" } ?? verb) Text(at, format: Self.stamp) .foregroundStyle(.tertiary) } .font(.gbSans(.caption)) .foregroundStyle(.secondary) } /// Reviews and checks are read for when they happened, so they carry /// the date rather than "3 days ago". private static let stamp = Date.FormatStyle.dateTime .year().month(.abbreviated).day().hour().minute() 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") } } /// Shown only when this merge request is part of a stack. Each row /// spells out its own direction — "stacked on" vs. "is stacked on /// this" mean opposite things and look alike if abbreviated. @ViewBuilder private func stackSection(_ mr: MRDetail) -> some View { if mr.stackedOn != nil || !mr.stacked.isEmpty { Section("Stack") { if let base = mr.stackedOn { NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: base.number)) { Label("stacked on !\(base.number) — \(base.title)", systemImage: "arrow.up.to.line") .font(.gbSans(.subheadline)) .lineLimit(1) } } ForEach(mr.stacked) { ref in NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: ref.number)) { Label("!\(ref.number) is stacked on this — \(ref.title)", systemImage: "arrow.down.to.line") .font(.gbSans(.subheadline)) .lineLimit(1) } } } } } /// Every head the merge request has had. A revision after the first /// links to the range-diff from the one before it. private var revisionsSection: some View { Section("Revisions") { ForEach(Array(model.revisions.enumerated()), id: \.element.id) { index, revision in let row = HStack { Text("v\(revision.n)") .font(.gbSans(.subheadline).weight(.medium)) Text(String(revision.sha.prefix(10))) .font(.gbMono(.caption)) .foregroundStyle(.secondary) Spacer() if revision.isCurrent { GBChip("current", .secondary) } Text(revision.createdAt.prefix(10)) .font(.gbSans(.caption)) .foregroundStyle(.tertiary) } if index > 0 { NavigationLink(value: MRRoute.rangeDiff( repo: model.repoPath, number: model.number, from: model.revisions[index - 1].sha, to: revision.sha) ) { row } } else { row } } } } private func reviewersSection(_ mr: MRDetail) -> some View { Section("Reviewers") { reviewerFlow(mr.reviewRequests) HStack { TextField("Request review", text: $editingReviewer) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button { let user = editingReviewer.trimmingCharacters(in: .whitespaces) editingReviewer = "" Task { await model.requestReview(from: user) } } label: { Image(systemName: "plus.circle.fill") } .disabled(editingReviewer.trimmingCharacters(in: .whitespaces).isEmpty || model.working) } } } /// The same add/remove chip shape `IssueView` uses for labels and /// assignees. @ViewBuilder private func reviewerFlow(_ items: [String]) -> some View { if !items.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(items, id: \.self) { item in HStack(spacing: 3) { Text(item) Button { Task { await model.removeReviewRequest(item) } } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .disabled(model.working) } .font(.gbSans(.caption)) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.secondary.opacity(0.07), in: gbChipShape) .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1)) } } } } } 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 VStack(alignment: .leading, spacing: 2) { 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) } timing(check.updatedAt, duration: check.duration) } } } } private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View { Section("Reviews") { ForEach(reviews) { review in VStack(alignment: .leading, spacing: 2) { 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) } } timing(review.createdAt) } } } } /// When a review or check last said something, and for a CI check how /// long its build ran. Nothing renders against a server that does not /// report the time. @ViewBuilder private func timing(_ at: Date?, duration: String? = nil) -> some View { if let at { HStack(spacing: 6) { Text(at, format: Self.stamp) if let duration { Text("·") Text(duration) } } .font(.gbSans(.caption)) .foregroundStyle(.tertiary) } } 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) } FormattedBodyView(text: comment.body, format: comment.bodyFormat) .font(.gbSans(.subheadline)) } .padding(.vertical, 2) } HStack { TextField("Comment", text: $commentText, axis: .vertical) .lineLimit(1...5) Picker("Format", selection: $commentFormat) { Text("Markdown").tag("md") Text("Org").tag("org") } .pickerStyle(.segmented) .labelsHidden() .fixedSize() .accessibilityIdentifier("mr-comment-format") Button { let text = commentText commentText = "" Task { await model.comment(text, format: commentFormat) } } 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 ?? "" draftFormat = mr.bodyFormat ?? "md" 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 { Task { await model.setDraft(!mr.draft) } } label: { Label(mr.draft ? "Mark as ready" : "Mark as draft", systemImage: mr.draft ? "checkmark.circle" : "pencil.circle") } Button { retargeting = true } label: { Label("Retarget", systemImage: "arrow.triangle.branch") } Divider() Button { confirmingMerge = true } label: { Label(mr.draft ? "Merge (mark as ready first)" : "Merge", systemImage: "arrow.triangle.merge") } .disabled(mr.draft) 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) } } /// A single-purpose branch picker for retarget. `model.branches` is /// `nil` while loading and `model.branchesError` distinguishes a failed /// `repo refs` fetch from a repository that genuinely has no other /// branches — an empty list must not read as a failed load. private struct RetargetSheet: View { let model: MRDetailViewModel let onPick: (String) -> Void @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Group { if let error = model.branchesError { ContentUnavailableView { Label("Could not load branches", systemImage: "wifi.exclamationmark") } description: { Text(error) } } else if let branches = model.branches { if branches.isEmpty { ContentUnavailableView { Label("No other branches", systemImage: "arrow.triangle.branch") } description: { Text("This repository has no other branches to retarget to.") } } else { List(branches, id: \.self) { branch in Button(branch) { onPick(branch) } } } } else { ProgressView() } } .navigationTitle("Retarget !\(model.number)") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } .task { await model.loadBranches() } } } }