gitbay/Views/MRs/MRView.swift
802 lines · 32147 bytes
1import SwiftUI
2
3struct MRView: View {
4
5 @State private var model: MRDetailViewModel
6 @State private var commentText = ""
7 @State private var commentFormat = "md"
8 @State private var confirmingMerge = false
9 @State private var confirmingClose = false
10 @State private var editing = false
11 @State private var draftTitle = ""
12 @State private var draftBody = ""
13 @State private var draftFormat = "md"
14 @State private var editingReviewer = ""
15 @State private var retargeting = false
16 @State private var pendingRetarget: String?
17 @State private var superseding = false
18 @State private var pendingSuccessor: Int64?
19
20 init(client: GitbayClient, repo: String, number: Int64) {
21 _model = State(initialValue: MRDetailViewModel(
22 client: client, repoPath: repo, number: number
23 ))
24 }
25
26 var body: some View {
27 List {
28 if let mr = model.state.value {
29 header(mr)
30
31 if let error = model.actionError {
32 Section {
33 GBNotice(error, .gbWarn)
34 }
35 }
36
37 if let body = mr.body, !body.isEmpty {
38 Section {
39 FormattedBodyView(text: body, format: mr.bodyFormat)
40 .padding(.vertical, 4)
41 }
42 }
43
44 milestoneSection(mr)
45 stackSection(mr)
46 supersededSection(mr)
47 reviewersSection(mr)
48 diffSection
49 if !model.revisions.isEmpty {
50 revisionsSection
51 }
52
53 if let commits = mr.commits, !commits.isEmpty {
54 commitsSection(commits)
55 }
56 if let checks = mr.checks, !checks.isEmpty {
57 checksSection(checks, combined: mr.checksCombined)
58 }
59 if let gates = mr.gates, mr.isOpen {
60 gatesSection(gates)
61 }
62 if let reviews = mr.reviews, !reviews.isEmpty {
63 reviewsSection(reviews)
64 }
65 if !model.threads.isEmpty {
66 threadsSection
67 }
68 commentsSection(mr.comments ?? [])
69 }
70 }
71 .overlay { LoadStateOverlay(state: model.state) }
72 .navigationTitle("!\(model.number)")
73 .navigationBarTitleDisplayMode(.inline)
74 .toolbar { toolbar }
75 .task { await model.load() }
76 .refreshable { await model.load() }
77 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
78 if let mr = model.state.value, !mr.stacked.isEmpty {
79 Button("Merge") { Task { await model.merge() } }
80 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
81 } else {
82 Button("Merge") { Task { await model.merge() } }
83 Button("Squash") { Task { await model.merge(strategy: "squash") } }
84 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
85 Button("Rebase") { Task { await model.merge(strategy: "rebase") } }
86 }
87 Button("Cancel", role: .cancel) {}
88 } message: {
89 if let mr = model.state.value, !mr.stacked.isEmpty {
90 Text("Merge requests are stacked on this one. Squash and rebase are unavailable — they would rewrite the commits the stack builds on.")
91 } else {
92 Text("The server enforces approvals, threads and checks — a refusal will say why.")
93 }
94 }
95 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
96 Button("Close", role: .destructive) { Task { await model.close() } }
97 Button("Close in favour of another…") { superseding = true }
98 Button("Cancel", role: .cancel) {}
99 }
100 .confirmationDialog(
101 "Close !\(model.number) in favour of !\(pendingSuccessor ?? 0)?",
102 isPresented: .init(
103 get: { pendingSuccessor != nil },
104 set: { if !$0 { pendingSuccessor = nil } }
105 ),
106 titleVisibility: .visible
107 ) {
108 Button("Close", role: .destructive) {
109 guard let successor = pendingSuccessor else { return }
110 pendingSuccessor = nil
111 Task { await model.close(inFavourOf: successor) }
112 }
113 Button("Cancel", role: .cancel) { pendingSuccessor = nil }
114 } message: {
115 Text("The page will name the merge request that replaces this one, and that one will list this as superseded.")
116 }
117 .sheet(isPresented: $superseding) {
118 SupersedeSheet(model: model) { successor in
119 superseding = false
120 pendingSuccessor = successor
121 }
122 }
123 .confirmationDialog(
124 "Retarget to \(pendingRetarget ?? "")?",
125 isPresented: .init(
126 get: { pendingRetarget != nil },
127 set: { if !$0 { pendingRetarget = nil } }
128 ),
129 titleVisibility: .visible
130 ) {
131 Button("Retarget") {
132 guard let branch = pendingRetarget else { return }
133 pendingRetarget = nil
134 Task { await model.retarget(to: branch) }
135 }
136 Button("Cancel", role: .cancel) { pendingRetarget = nil }
137 } message: {
138 Text("Existing approvals were of the diff against the old target and will no longer apply.")
139 }
140 .sheet(isPresented: $retargeting) {
141 RetargetSheet(model: model) { branch in
142 retargeting = false
143 pendingRetarget = branch
144 }
145 }
146 .sheet(isPresented: $editing) {
147 ComposeSheet(
148 heading: "Edit !\(model.number)",
149 submitLabel: "Save",
150 working: model.working,
151 errorMessage: model.actionError,
152 title: $draftTitle,
153 bodyText: $draftBody,
154 format: $draftFormat
155 ) {
156 Task {
157 await model.edit(title: draftTitle, body: draftBody, format: draftFormat)
158 if model.actionError == nil { editing = false }
159 }
160 }
161 }
162 }
163
164 // MARK: - Sections
165
166 @ViewBuilder
167 private func header(_ mr: MRDetail) -> some View {
168 Section {
169 VStack(alignment: .leading, spacing: 6) {
170 Text(mr.title)
171 .font(.gbSans(.headline))
172 HStack(spacing: 6) {
173 MRStateBadge(state: mr.state)
174 if mr.draft {
175 GBChip("draft", .secondary)
176 }
177 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
178 .lineLimit(1)
179 Image(systemName: "arrow.right")
180 .font(.gbSans(.caption2))
181 Text(mr.targetRef)
182 }
183 .font(.gbSans(.caption))
184 .foregroundStyle(.secondary)
185 HStack(spacing: 6) {
186 Text("by \(mr.author)")
187 Text(mr.createdAt, format: .relative(presentation: .named))
188 .foregroundStyle(.tertiary)
189 }
190 .font(.gbSans(.caption))
191 .foregroundStyle(.secondary)
192 resolution(mr)
193 }
194 .padding(.vertical, 2)
195 }
196 }
197
198 /// What became of the merge request, once it is no longer open. The
199 /// actor is absent for imported rows, which carry only a time.
200 @ViewBuilder
201 private func resolution(_ mr: MRDetail) -> some View {
202 if let at = mr.mergedAt {
203 stampLine("merged", by: mr.mergedBy, at: at)
204 } else if let at = mr.closedAt {
205 stampLine(mr.supersededBy.map { "closed in favour of !\($0)" } ?? "closed without merging",
206 by: mr.closedBy, at: at)
207 }
208 }
209
210 private func stampLine(_ verb: String, by actor: String?, at: Date) -> some View {
211 HStack(spacing: 6) {
212 Text(actor.map { "\(verb) by \($0)" } ?? verb)
213 Text(at, format: Self.stamp)
214 .foregroundStyle(.tertiary)
215 }
216 .font(.gbSans(.caption))
217 .foregroundStyle(.secondary)
218 }
219
220 /// Reviews and checks are read for when they happened, so they carry
221 /// the date rather than "3 days ago".
222 private static let stamp = Date.FormatStyle.dateTime
223 .year().month(.abbreviated).day().hour().minute()
224
225 private func milestoneSection(_ mr: MRDetail) -> some View {
226 Section("Milestone") {
227 Menu {
228 Button("None") { Task { await model.setMilestone(nil) } }
229 ForEach(model.availableMilestones ?? []) { milestone in
230 Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") {
231 Task { await model.setMilestone(milestone.title) }
232 }
233 }
234 } label: {
235 HStack {
236 Label(mr.milestone ?? "None", systemImage: "flag")
237 .font(.gbSans(.subheadline))
238 Spacer()
239 Image(systemName: "chevron.up.chevron.down")
240 .font(.gbSans(.caption2))
241 .foregroundStyle(.secondary)
242 }
243 }
244 .disabled(model.working)
245 .task { await model.loadMilestones() }
246 .accessibilityIdentifier("mr-milestone-menu")
247 }
248 }
249
250 /// Shown only when this merge request is part of a stack. Each row
251 /// spells out its own direction — "stacked on" vs. "is stacked on
252 /// this" mean opposite things and look alike if abbreviated.
253 /// The merge request that replaced this one, as a link; the stamp
254 /// line names it, this row reaches it.
255 @ViewBuilder
256 private func supersededSection(_ mr: MRDetail) -> some View {
257 if let successor = mr.supersededBy {
258 Section {
259 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: successor)) {
260 Label("Superseded by !\(successor)", systemImage: "arrow.right.to.line")
261 .font(.gbSans(.subheadline))
262 }
263 }
264 }
265 }
266
267 @ViewBuilder
268 private func stackSection(_ mr: MRDetail) -> some View {
269 if mr.stackedOn != nil || !mr.stacked.isEmpty {
270 Section("Stack") {
271 if let base = mr.stackedOn {
272 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: base.number)) {
273 Label("stacked on !\(base.number) — \(base.title)", systemImage: "arrow.up.to.line")
274 .font(.gbSans(.subheadline))
275 .lineLimit(1)
276 }
277 }
278 ForEach(mr.stacked) { ref in
279 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: ref.number)) {
280 Label("!\(ref.number) is stacked on this — \(ref.title)", systemImage: "arrow.down.to.line")
281 .font(.gbSans(.subheadline))
282 .lineLimit(1)
283 }
284 }
285 }
286 }
287 }
288
289 /// Every head the merge request has had. A revision after the first
290 /// links to the range-diff from the one before it.
291 private var revisionsSection: some View {
292 Section("Revisions") {
293 ForEach(Array(model.revisions.enumerated()), id: \.element.id) { index, revision in
294 let row = HStack {
295 Text("v\(revision.n)")
296 .font(.gbSans(.subheadline).weight(.medium))
297 Text(String(revision.sha.prefix(10)))
298 .font(.gbMono(.caption))
299 .foregroundStyle(.secondary)
300 Spacer()
301 if revision.isCurrent {
302 GBChip("current", .secondary)
303 }
304 Text(revision.createdAt.prefix(10))
305 .font(.gbSans(.caption))
306 .foregroundStyle(.tertiary)
307 }
308 if index > 0 {
309 NavigationLink(value: MRRoute.rangeDiff(
310 repo: model.repoPath, number: model.number,
311 from: model.revisions[index - 1].sha, to: revision.sha)
312 ) {
313 row
314 }
315 } else {
316 row
317 }
318 }
319 }
320 }
321
322 private func reviewersSection(_ mr: MRDetail) -> some View {
323 Section("Reviewers") {
324 reviewerFlow(mr.reviewRequests)
325 HStack {
326 TextField("Request review", text: $editingReviewer)
327 .autocorrectionDisabled()
328 .textInputAutocapitalization(.never)
329 Button {
330 let user = editingReviewer.trimmingCharacters(in: .whitespaces)
331 editingReviewer = ""
332 Task { await model.requestReview(from: user) }
333 } label: {
334 Image(systemName: "plus.circle.fill")
335 }
336 .disabled(editingReviewer.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
337 }
338 }
339 }
340
341 /// The same add/remove chip shape `IssueView` uses for labels and
342 /// assignees.
343 @ViewBuilder
344 private func reviewerFlow(_ items: [String]) -> some View {
345 if !items.isEmpty {
346 ScrollView(.horizontal, showsIndicators: false) {
347 HStack(spacing: 6) {
348 ForEach(items, id: \.self) { item in
349 HStack(spacing: 3) {
350 Text(item)
351 Button {
352 Task { await model.removeReviewRequest(item) }
353 } label: {
354 Image(systemName: "xmark.circle.fill")
355 .foregroundStyle(.tertiary)
356 }
357 .disabled(model.working)
358 }
359 .font(.gbSans(.caption))
360 .padding(.horizontal, 8)
361 .padding(.vertical, 3)
362 .background(Color.secondary.opacity(0.07), in: gbChipShape)
363 .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1))
364 }
365 }
366 }
367 }
368 }
369
370 private var diffSection: some View {
371 Section {
372 NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) {
373 HStack {
374 Label("Diff", systemImage: "plus.forwardslash.minus")
375 Spacer()
376 if let diff = model.diff {
377 Text("+\(diff.additions)")
378 .foregroundStyle(Color.gbOK)
379 Text("−\(diff.deletions)")
380 .foregroundStyle(Color.gbBad)
381 }
382 }
383 .font(.gbSans(.subheadline))
384 }
385 }
386 }
387
388 private func commitsSection(_ commits: [MRDetail.MRCommit]) -> some View {
389 Section("Commits") {
390 ForEach(commits) { commit in
391 HStack(spacing: 8) {
392 Text(commit.shortSHA)
393 .font(.gbMono(.caption))
394 .foregroundStyle(.secondary)
395 Text(commit.subject)
396 .font(.gbSans(.subheadline))
397 .lineLimit(1)
398 }
399 }
400 }
401 }
402
403 private func checksSection(_ checks: [MRDetail.Check], combined: String?) -> some View {
404 Section("Checks" + (combined.map { " — \($0)" } ?? "")) {
405 ForEach(checks, id: \.context) { check in
406 VStack(alignment: .leading, spacing: 2) {
407 HStack {
408 Image(systemName: checkIcon(check.state))
409 .foregroundStyle(checkColor(check.state))
410 Text(check.context)
411 .font(.gbSans(.subheadline))
412 Spacer()
413 Text(check.state)
414 .font(.gbSans(.caption))
415 .foregroundStyle(.secondary)
416 }
417 timing(check.updatedAt, duration: check.duration)
418 }
419 }
420 }
421 }
422
423 /// The merge gates as the server will judge them, each row met or
424 /// not, so a refusal is never the first time a rule is seen.
425 private func gatesSection(_ gates: MRDetail.Gates) -> some View {
426 Section {
427 if gates.draft {
428 gateRow(false, "Draft — not asking to merge")
429 }
430 if gates.approvalsRequired > 0 {
431 gateRow(gates.approvals.count >= gates.approvalsRequired,
432 "\(gates.approvals.count) of \(gates.approvalsRequired) approval\(gates.approvalsRequired == 1 ? "" : "s")"
433 + (gates.approvals.isEmpty ? "" : " — " + gates.approvals.joined(separator: ", ")))
434 }
435 if !gates.changesRequested.isEmpty {
436 gateRow(false, "Changes requested by " + gates.changesRequested.joined(separator: ", "))
437 }
438 if gates.codeownersRequired {
439 if gates.ownersOutstanding.isEmpty {
440 gateRow(true, "Code owners approved")
441 } else {
442 ForEach(gates.ownersOutstanding) { owners in
443 gateRow(false, owners.files.joined(separator: ", ") + " — " + owners.owners.joined(separator: ", "))
444 }
445 }
446 }
447 if gates.resolvedRequired {
448 gateRow(gates.openThreads == 0,
449 gates.openThreads == 0 ? "Threads resolved" : "\(gates.openThreads) thread\(gates.openThreads == 1 ? "" : "s") open")
450 }
451 if gates.checksRequired {
452 gateRow(gates.checks == "success", "Checks " + (gates.checks ?? "not reported"))
453 }
454 gateRow(gates.fastForward, gates.fastForward ? "Fast-forward possible" : "Behind the target — rebase first")
455 } header: {
456 Text("Gates")
457 } footer: {
458 Text(gates.isMet ? "Ready to merge." : "Unmet: " + gates.unmet.joined(separator: ", "))
459 }
460 }
461
462 private func gateRow(_ met: Bool, _ text: String) -> some View {
463 HStack(alignment: .firstTextBaseline) {
464 Image(systemName: met ? "checkmark.circle.fill" : "circle")
465 .foregroundStyle(met ? Color.gbOK : Color.gbWarn)
466 Text(text)
467 .font(.gbSans(.subheadline))
468 }
469 }
470
471 private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View {
472 Section("Reviews") {
473 ForEach(reviews) { review in
474 VStack(alignment: .leading, spacing: 2) {
475 HStack {
476 Image(systemName: review.verdict == "approve"
477 ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
478 .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn)
479 Text(review.reviewer)
480 .font(.gbSans(.subheadline))
481 Spacer()
482 if review.stale {
483 GBChip("stale", .gbWarn)
484 }
485 if !review.counts {
486 GBChip("advisory", .secondary)
487 }
488 }
489 timing(review.createdAt)
490 }
491 }
492 }
493 }
494
495 /// When a review or check last said something, and for a CI check how
496 /// long its build ran. Nothing renders against a server that does not
497 /// report the time.
498 @ViewBuilder
499 private func timing(_ at: Date?, duration: String? = nil) -> some View {
500 if let at {
501 HStack(spacing: 6) {
502 Text(at, format: Self.stamp)
503 if let duration {
504 Text("·")
505 Text(duration)
506 }
507 }
508 .font(.gbSans(.caption))
509 .foregroundStyle(.tertiary)
510 }
511 }
512
513 private var threadsSection: some View {
514 Section("Threads — \(model.unresolvedCount) unresolved") {
515 ForEach(model.threads) { thread in
516 ReviewThreadView(thread: thread, model: model)
517 }
518 }
519 }
520
521 private func commentsSection(_ comments: [MRDetail.MRComment]) -> some View {
522 Section("Comments") {
523 ForEach(comments) { comment in
524 VStack(alignment: .leading, spacing: 4) {
525 HStack {
526 Text(comment.author)
527 .font(.gbSans(.caption).weight(.semibold))
528 Text(comment.createdAt, format: .relative(presentation: .named))
529 .font(.gbSans(.caption))
530 .foregroundStyle(.tertiary)
531 }
532 FormattedBodyView(text: comment.body, format: comment.bodyFormat)
533 .font(.gbSans(.subheadline))
534 }
535 .padding(.vertical, 2)
536 }
537
538 HStack {
539 TextField("Comment", text: $commentText, axis: .vertical)
540 .lineLimit(1...5)
541 Picker("Format", selection: $commentFormat) {
542 Text("Markdown").tag("md")
543 Text("Org").tag("org")
544 }
545 .pickerStyle(.segmented)
546 .labelsHidden()
547 .fixedSize()
548 .accessibilityIdentifier("mr-comment-format")
549 Button {
550 let text = commentText
551 commentText = ""
552 Task { await model.comment(text, format: commentFormat) }
553 } label: {
554 Image(systemName: "arrow.up.circle.fill")
555 }
556 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
557 || model.working)
558 }
559 }
560 }
561
562 @ToolbarContentBuilder
563 private var toolbar: some ToolbarContent {
564 ToolbarItem(placement: .topBarTrailing) {
565 if let mr = model.state.value, mr.isOpen {
566 Menu {
567 Button {
568 draftTitle = mr.title
569 draftBody = mr.body ?? ""
570 draftFormat = mr.bodyFormat ?? "md"
571 editing = true
572 } label: {
573 Label("Edit", systemImage: "pencil")
574 }
575 Divider()
576 Button {
577 Task { await model.review(.approve) }
578 } label: {
579 Label("Approve", systemImage: "checkmark.circle")
580 }
581 Button {
582 Task { await model.review(.requestChanges) }
583 } label: {
584 Label("Request Changes", systemImage: "exclamationmark.circle")
585 }
586 Divider()
587 Button {
588 Task { await model.setDraft(!mr.draft) }
589 } label: {
590 Label(mr.draft ? "Mark as ready" : "Mark as draft",
591 systemImage: mr.draft ? "checkmark.circle" : "pencil.circle")
592 }
593 Button {
594 retargeting = true
595 } label: {
596 Label("Retarget", systemImage: "arrow.triangle.branch")
597 }
598 Divider()
599 Button {
600 confirmingMerge = true
601 } label: {
602 Label(mr.draft ? "Merge (mark as ready first)" : "Merge",
603 systemImage: "arrow.triangle.merge")
604 }
605 .disabled(mr.draft)
606 Button(role: .destructive) {
607 confirmingClose = true
608 } label: {
609 Label("Close", systemImage: "xmark.circle")
610 }
611 } label: {
612 if model.working {
613 ProgressView()
614 } else {
615 Image(systemName: "ellipsis.circle")
616 }
617 }
618 .disabled(model.working)
619 .accessibilityIdentifier("mr-actions-menu")
620 }
621 }
622 }
623
624 private func checkIcon(_ state: String) -> String {
625 switch state {
626 case "success": "checkmark.circle.fill"
627 case "failure", "error": "xmark.circle.fill"
628 case "pending", "running": "circle.dotted"
629 default: "questionmark.circle"
630 }
631 }
632
633 private func checkColor(_ state: String) -> Color {
634 switch state {
635 case "success": .gbOK
636 case "failure", "error": .gbBad
637 case "pending", "running": .gbWarn
638 default: .secondary
639 }
640 }
641}
642
643/// One review thread: anchor, comments, reply, resolve.
644struct ReviewThreadView: View {
645
646 let thread: ReviewThread
647 let model: MRDetailViewModel
648 @State private var replyText = ""
649
650 var body: some View {
651 VStack(alignment: .leading, spacing: 6) {
652 HStack(spacing: 6) {
653 Image(systemName: thread.isResolved
654 ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
655 .font(.gbSans(.caption))
656 .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn)
657 Text("\(thread.path):\(thread.line)")
658 .font(.gbMono(.caption))
659 .lineLimit(1)
660 if thread.stale {
661 GBChip("stale", .gbWarn)
662 }
663 Spacer()
664 Button(thread.isResolved ? "Reopen" : "Resolve") {
665 Task { await model.setResolved(thread, !thread.isResolved) }
666 }
667 .font(.gbSans(.caption))
668 .buttonStyle(.bordered)
669 .disabled(model.working)
670 }
671 ForEach(thread.comments) { comment in
672 VStack(alignment: .leading, spacing: 2) {
673 HStack {
674 Text(comment.author)
675 .font(.gbSans(.caption).weight(.semibold))
676 Text(comment.createdAt, format: .relative(presentation: .named))
677 .font(.gbSans(.caption2))
678 .foregroundStyle(.tertiary)
679 }
680 Text(comment.body)
681 .font(.gbSans(.subheadline))
682 }
683 }
684 if !thread.isResolved {
685 HStack {
686 TextField("Reply", text: $replyText, axis: .vertical)
687 .font(.gbSans(.subheadline))
688 .lineLimit(1...4)
689 Button {
690 let text = replyText
691 replyText = ""
692 Task { await model.reply(to: thread, text) }
693 } label: {
694 Image(systemName: "arrow.up.circle.fill")
695 }
696 .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
697 || model.working)
698 }
699 }
700 }
701 .padding(.vertical, 4)
702 }
703}
704
705/// A single-purpose branch picker for retarget. `model.branches` is
706/// `nil` while loading and `model.branchesError` distinguishes a failed
707/// `repo refs` fetch from a repository that genuinely has no other
708/// branches — an empty list must not read as a failed load.
709/// The repository's other open merge requests, one of which will be
710/// recorded as replacing this one.
711private struct SupersedeSheet: View {
712
713 let model: MRDetailViewModel
714 let onPick: (Int64) -> Void
715
716 @Environment(\.dismiss) private var dismiss
717
718 var body: some View {
719 NavigationStack {
720 Group {
721 if let error = model.openMergeRequestsError {
722 ContentUnavailableView {
723 Label("Could not load merge requests", systemImage: "wifi.exclamationmark")
724 } description: {
725 Text(error)
726 }
727 } else if let candidates = model.openMergeRequests {
728 if candidates.isEmpty {
729 ContentUnavailableView {
730 Label("No other open merge requests", systemImage: "arrow.triangle.pull")
731 } description: {
732 Text("Only an open merge request in this repository can supersede this one.")
733 }
734 } else {
735 List(candidates) { mr in
736 Button {
737 onPick(mr.number)
738 } label: {
739 MRRow(mr: mr)
740 }
741 .tint(.primary)
742 }
743 }
744 } else {
745 ProgressView()
746 }
747 }
748 .navigationTitle("Close !\(model.number) in favour of")
749 .navigationBarTitleDisplayMode(.inline)
750 .toolbar {
751 ToolbarItem(placement: .cancellationAction) {
752 Button("Cancel") { dismiss() }
753 }
754 }
755 .task { await model.loadOpenMergeRequests() }
756 }
757 }
758}
759
760private struct RetargetSheet: View {
761
762 let model: MRDetailViewModel
763 let onPick: (String) -> Void
764
765 @Environment(\.dismiss) private var dismiss
766
767 var body: some View {
768 NavigationStack {
769 Group {
770 if let error = model.branchesError {
771 ContentUnavailableView {
772 Label("Could not load branches", systemImage: "wifi.exclamationmark")
773 } description: {
774 Text(error)
775 }
776 } else if let branches = model.branches {
777 if branches.isEmpty {
778 ContentUnavailableView {
779 Label("No other branches", systemImage: "arrow.triangle.branch")
780 } description: {
781 Text("This repository has no other branches to retarget to.")
782 }
783 } else {
784 List(branches, id: \.self) { branch in
785 Button(branch) { onPick(branch) }
786 }
787 }
788 } else {
789 ProgressView()
790 }
791 }
792 .navigationTitle("Retarget !\(model.number)")
793 .navigationBarTitleDisplayMode(.inline)
794 .toolbar {
795 ToolbarItem(placement: .cancellationAction) {
796 Button("Cancel") { dismiss() }
797 }
798 }
799 .task { await model.loadBranches() }
800 }
801 }
802}