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