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 editingLabel = "" @State private var retargeting = false @State private var pendingRetarget: String? @State private var superseding = false @State private var pendingSuccessor: Int64? 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, repo: model.repoPath) .padding(.vertical, 4) } } if let gates = mr.gates, mr.isOpen { gatesSection(gates) } if let checks = mr.checks, !checks.isEmpty { checksSection(checks, combined: mr.checksCombined) } milestoneSection(mr) labelsSection(mr) stackSection(mr) supersededSection(mr) reviewersSection(mr) diffSection if !model.revisions.isEmpty { revisionsSection } if let commits = mr.commits, !commits.isEmpty { commitsSection(commits) } if let reviews = mr.reviews, !reviews.isEmpty { reviewsSection(reviews) } if !model.threads.isEmpty { threadsSection } commentsSection(mr.comments ?? []) } } .overlay { LoadStateOverlay(state: model.state) } .referenceNavigation() .scrollDismissesKeyboard(.interactively) .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("Close in favour of another…") { superseding = true } Button("Cancel", role: .cancel) {} } .confirmationDialog( "Close !\(model.number) in favour of !\(pendingSuccessor ?? 0)?", isPresented: .init( get: { pendingSuccessor != nil }, set: { if !$0 { pendingSuccessor = nil } } ), titleVisibility: .visible ) { Button("Close", role: .destructive) { guard let successor = pendingSuccessor else { return } pendingSuccessor = nil Task { await model.close(inFavourOf: successor) } } Button("Cancel", role: .cancel) { pendingSuccessor = nil } } message: { Text("The page will name the merge request that replaces this one, and that one will list this as superseded.") } .sheet(isPresented: $superseding) { SupersedeSheet(model: model) { successor in superseding = false pendingSuccessor = successor } } .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(mr.supersededBy.map { "closed in favour of !\($0)" } ?? "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") } } private func labelsSection(_ mr: MRDetail) -> some View { Section("Labels") { RemovableChips(items: mr.labels, idPrefix: "mr-label", disabled: model.working, color: { model.colors.color($0) }, remove: { label in Task { await model.removeLabel(label) } }) HStack { TextField("Add label", text: $editingLabel) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("mr-label-field") Button { let label = editingLabel.trimmingCharacters(in: .whitespaces) editingLabel = "" Task { await model.addLabel(label) } } label: { Image(systemName: "plus.circle.fill") } .accessibilityLabel("Add label") .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working) .accessibilityIdentifier("mr-label-add") } } .task { await model.loadLabels() } } /// 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. /// The merge request that replaced this one, as a link; the stamp /// line names it, this row reaches it. @ViewBuilder private func supersededSection(_ mr: MRDetail) -> some View { if let successor = mr.supersededBy { Section { NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: successor)) { Label("Superseded by !\(successor)", systemImage: "arrow.right.to.line") .font(.gbSans(.subheadline)) } } } } @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") { RemovableChips(items: mr.reviewRequests, idPrefix: "mr-reviewer", disabled: model.working, remove: { user in Task { await model.removeReviewRequest(user) } }) 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") } .accessibilityLabel("Request review") .disabled(editingReviewer.trimmingCharacters(in: .whitespaces).isEmpty || model.working) } } } 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) } } } } /// The merge gates as the server will judge them, each row met or /// not, so a refusal is never the first time a rule is seen. private func gatesSection(_ gates: MRDetail.Gates) -> some View { Section { if gates.draft { gateRow(false, "Draft — not asking to merge") } if gates.approvalsRequired > 0 { gateRow(gates.approvals.count >= gates.approvalsRequired, "\(gates.approvals.count) of \(gates.approvalsRequired) approval\(gates.approvalsRequired == 1 ? "" : "s")" + (gates.approvals.isEmpty ? "" : " — " + gates.approvals.joined(separator: ", "))) } if !gates.changesRequested.isEmpty { gateRow(false, "Changes requested by " + gates.changesRequested.joined(separator: ", ")) } if gates.codeownersRequired { if gates.ownersOutstanding.isEmpty { gateRow(true, "Code owners approved") } else { ForEach(gates.ownersOutstanding) { owners in gateRow(false, owners.files.joined(separator: ", ") + " — " + owners.owners.joined(separator: ", ")) } } } if gates.resolvedRequired { gateRow(gates.openThreads == 0, gates.openThreads == 0 ? "Threads resolved" : "\(gates.openThreads) thread\(gates.openThreads == 1 ? "" : "s") open") } if gates.checksRequired { gateRow(gates.checks == "success", "Checks " + (gates.checks ?? "not reported")) } gateRow(gates.fastForward, gates.fastForward ? "Fast-forward possible" : "Behind the target — rebase first") } header: { Text("Gates") } footer: { Text(gates.isMet ? "Ready to merge." : "Unmet: " + gates.unmet.joined(separator: ", ")) } } private func gateRow(_ met: Bool, _ text: String) -> some View { HStack(alignment: .firstTextBaseline) { Image(systemName: met ? "checkmark.circle.fill" : "circle") .foregroundStyle(met ? Color.gbOK : Color.gbWarn) Text(text) .font(.gbSans(.subheadline)) } } 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) } if !review.counts { GBChip("advisory", .secondary) } } 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, repo: model.repoPath) .font(.gbSans(.subheadline)) } .padding(.vertical, 2) } HStack { TextField("Comment", text: $commentText, axis: .vertical) .lineLimit(1...5) .accessibilityIdentifier("mr-comment-field") 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") .font(.title) .frame(minWidth: 44, minHeight: 44) .contentShape(Rectangle()) } .accessibilityLabel("Send comment") .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.working) } if !commentText.isEmpty { MarkupPreview(text: commentText, format: commentFormat, identifier: "mr-comment-preview") .font(.gbSans(.subheadline)) } } } @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") } } .accessibilityLabel("Actions") .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) } // Review comments carry no format; the web renders them as Markdown. FormattedBodyView(text: comment.body, repo: model.repoPath) .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") .font(.title) .frame(minWidth: 44, minHeight: 44) .contentShape(Rectangle()) } .accessibilityLabel("Send reply") .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. /// The repository's other open merge requests, one of which will be /// recorded as replacing this one. private struct SupersedeSheet: View { let model: MRDetailViewModel let onPick: (Int64) -> Void @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Group { if let error = model.openMergeRequestsError { ContentUnavailableView { Label("Could not load merge requests", systemImage: "wifi.exclamationmark") } description: { Text(error) } } else if let candidates = model.openMergeRequests { if candidates.isEmpty { ContentUnavailableView { Label("No other open merge requests", systemImage: "arrow.triangle.pull") } description: { Text("Only an open merge request in this repository can supersede this one.") } } else { List(candidates) { mr in Button { onPick(mr.number) } label: { MRRow(mr: mr) } .tint(.primary) } } } else { ProgressView() } } .navigationTitle("Close !\(model.number) in favour of") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } .task { await model.loadOpenMergeRequests() } } } } 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() } } } }