gitbay/Views/MRs/MRView.swift
808 lines · 32636 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 .scrollDismissesKeyboard(.interactively)
75 .navigationTitle("!\(model.number)")
76 .navigationBarTitleDisplayMode(.inline)
77 .toolbar { toolbar }
78 .task { await model.load() }
79 .refreshable { await model.load() }
80 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
81 if let mr = model.state.value, !mr.stacked.isEmpty {
82 Button("Merge") { Task { await model.merge() } }
83 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
84 } else {
85 Button("Merge") { Task { await model.merge() } }
86 Button("Squash") { Task { await model.merge(strategy: "squash") } }
87 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
88 Button("Rebase") { Task { await model.merge(strategy: "rebase") } }
89 }
90 Button("Cancel", role: .cancel) {}
91 } message: {
92 if let mr = model.state.value, !mr.stacked.isEmpty {
93 Text("Merge requests are stacked on this one. Squash and rebase are unavailable — they would rewrite the commits the stack builds on.")
94 } else {
95 Text("The server enforces approvals, threads and checks — a refusal will say why.")
96 }
97 }
98 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
99 Button("Close", role: .destructive) { Task { await model.close() } }
100 Button("Close in favour of another…") { superseding = true }
101 Button("Cancel", role: .cancel) {}
102 }
103 .confirmationDialog(
104 "Close !\(model.number) in favour of !\(pendingSuccessor ?? 0)?",
105 isPresented: .init(
106 get: { pendingSuccessor != nil },
107 set: { if !$0 { pendingSuccessor = nil } }
108 ),
109 titleVisibility: .visible
110 ) {
111 Button("Close", role: .destructive) {
112 guard let successor = pendingSuccessor else { return }
113 pendingSuccessor = nil
114 Task { await model.close(inFavourOf: successor) }
115 }
116 Button("Cancel", role: .cancel) { pendingSuccessor = nil }
117 } message: {
118 Text("The page will name the merge request that replaces this one, and that one will list this as superseded.")
119 }
120 .sheet(isPresented: $superseding) {
121 SupersedeSheet(model: model) { successor in
122 superseding = false
123 pendingSuccessor = successor
124 }
125 }
126 .confirmationDialog(
127 "Retarget to \(pendingRetarget ?? "")?",
128 isPresented: .init(
129 get: { pendingRetarget != nil },
130 set: { if !$0 { pendingRetarget = nil } }
131 ),
132 titleVisibility: .visible
133 ) {
134 Button("Retarget") {
135 guard let branch = pendingRetarget else { return }
136 pendingRetarget = nil
137 Task { await model.retarget(to: branch) }
138 }
139 Button("Cancel", role: .cancel) { pendingRetarget = nil }
140 } message: {
141 Text("Existing approvals were of the diff against the old target and will no longer apply.")
142 }
143 .sheet(isPresented: $retargeting) {
144 RetargetSheet(model: model) { branch in
145 retargeting = false
146 pendingRetarget = branch
147 }
148 }
149 .sheet(isPresented: $editing) {
150 ComposeSheet(
151 heading: "Edit !\(model.number)",
152 submitLabel: "Save",
153 working: model.working,
154 errorMessage: model.actionError,
155 title: $draftTitle,
156 bodyText: $draftBody,
157 format: $draftFormat
158 ) {
159 Task {
160 await model.edit(title: draftTitle, body: draftBody, format: draftFormat)
161 if model.actionError == nil { editing = false }
162 }
163 }
164 }
165 }
166
167 // MARK: - Sections
168
169 @ViewBuilder
170 private func header(_ mr: MRDetail) -> some View {
171 Section {
172 VStack(alignment: .leading, spacing: 6) {
173 Text(mr.title)
174 .font(.gbSans(.headline))
175 HStack(spacing: 6) {
176 MRStateBadge(state: mr.state)
177 if mr.draft {
178 GBChip("draft", .secondary)
179 }
180 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
181 .lineLimit(1)
182 Image(systemName: "arrow.right")
183 .font(.gbSans(.caption2))
184 Text(mr.targetRef)
185 }
186 .font(.gbSans(.caption))
187 .foregroundStyle(.secondary)
188 HStack(spacing: 6) {
189 Text("by \(mr.author)")
190 Text(mr.createdAt, format: .relative(presentation: .named))
191 .foregroundStyle(.tertiary)
192 }
193 .font(.gbSans(.caption))
194 .foregroundStyle(.secondary)
195 resolution(mr)
196 }
197 .padding(.vertical, 2)
198 }
199 }
200
201 /// What became of the merge request, once it is no longer open. The
202 /// actor is absent for imported rows, which carry only a time.
203 @ViewBuilder
204 private func resolution(_ mr: MRDetail) -> some View {
205 if let at = mr.mergedAt {
206 stampLine("merged", by: mr.mergedBy, at: at)
207 } else if let at = mr.closedAt {
208 stampLine(mr.supersededBy.map { "closed in favour of !\($0)" } ?? "closed without merging",
209 by: mr.closedBy, at: at)
210 }
211 }
212
213 private func stampLine(_ verb: String, by actor: String?, at: Date) -> some View {
214 HStack(spacing: 6) {
215 Text(actor.map { "\(verb) by \($0)" } ?? verb)
216 Text(at, format: Self.stamp)
217 .foregroundStyle(.tertiary)
218 }
219 .font(.gbSans(.caption))
220 .foregroundStyle(.secondary)
221 }
222
223 /// Reviews and checks are read for when they happened, so they carry
224 /// the date rather than "3 days ago".
225 private static let stamp = Date.FormatStyle.dateTime
226 .year().month(.abbreviated).day().hour().minute()
227
228 private func milestoneSection(_ mr: MRDetail) -> some View {
229 Section("Milestone") {
230 Menu {
231 Button("None") { Task { await model.setMilestone(nil) } }
232 ForEach(model.availableMilestones ?? []) { milestone in
233 Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") {
234 Task { await model.setMilestone(milestone.title) }
235 }
236 }
237 } label: {
238 HStack {
239 Label(mr.milestone ?? "None", systemImage: "flag")
240 .font(.gbSans(.subheadline))
241 Spacer()
242 Image(systemName: "chevron.up.chevron.down")
243 .font(.gbSans(.caption2))
244 .foregroundStyle(.secondary)
245 }
246 }
247 .disabled(model.working)
248 .task { await model.loadMilestones() }
249 .accessibilityIdentifier("mr-milestone-menu")
250 }
251 }
252
253 private func labelsSection(_ mr: MRDetail) -> some View {
254 Section("Labels") {
255 RemovableChips(items: mr.labels, idPrefix: "mr-label",
256 disabled: model.working, color: { model.colors.color($0) },
257 remove: { label in Task { await model.removeLabel(label) } })
258 HStack {
259 TextField("Add label", text: $editingLabel)
260 .autocorrectionDisabled()
261 .textInputAutocapitalization(.never)
262 .accessibilityIdentifier("mr-label-field")
263 Button {
264 let label = editingLabel.trimmingCharacters(in: .whitespaces)
265 editingLabel = ""
266 Task { await model.addLabel(label) }
267 } label: {
268 Image(systemName: "plus.circle.fill")
269 }
270 .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
271 .accessibilityIdentifier("mr-label-add")
272 }
273 }
274 .task { await model.loadLabels() }
275 }
276
277 /// Shown only when this merge request is part of a stack. Each row
278 /// spells out its own direction — "stacked on" vs. "is stacked on
279 /// this" mean opposite things and look alike if abbreviated.
280 /// The merge request that replaced this one, as a link; the stamp
281 /// line names it, this row reaches it.
282 @ViewBuilder
283 private func supersededSection(_ mr: MRDetail) -> some View {
284 if let successor = mr.supersededBy {
285 Section {
286 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: successor)) {
287 Label("Superseded by !\(successor)", systemImage: "arrow.right.to.line")
288 .font(.gbSans(.subheadline))
289 }
290 }
291 }
292 }
293
294 @ViewBuilder
295 private func stackSection(_ mr: MRDetail) -> some View {
296 if mr.stackedOn != nil || !mr.stacked.isEmpty {
297 Section("Stack") {
298 if let base = mr.stackedOn {
299 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: base.number)) {
300 Label("stacked on !\(base.number) — \(base.title)", systemImage: "arrow.up.to.line")
301 .font(.gbSans(.subheadline))
302 .lineLimit(1)
303 }
304 }
305 ForEach(mr.stacked) { ref in
306 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: ref.number)) {
307 Label("!\(ref.number) is stacked on this — \(ref.title)", systemImage: "arrow.down.to.line")
308 .font(.gbSans(.subheadline))
309 .lineLimit(1)
310 }
311 }
312 }
313 }
314 }
315
316 /// Every head the merge request has had. A revision after the first
317 /// links to the range-diff from the one before it.
318 private var revisionsSection: some View {
319 Section("Revisions") {
320 ForEach(Array(model.revisions.enumerated()), id: \.element.id) { index, revision in
321 let row = HStack {
322 Text("v\(revision.n)")
323 .font(.gbSans(.subheadline).weight(.medium))
324 Text(String(revision.sha.prefix(10)))
325 .font(.gbMono(.caption))
326 .foregroundStyle(.secondary)
327 Spacer()
328 if revision.isCurrent {
329 GBChip("current", .secondary)
330 }
331 Text(revision.createdAt.prefix(10))
332 .font(.gbSans(.caption))
333 .foregroundStyle(.tertiary)
334 }
335 if index > 0 {
336 NavigationLink(value: MRRoute.rangeDiff(
337 repo: model.repoPath, number: model.number,
338 from: model.revisions[index - 1].sha, to: revision.sha)
339 ) {
340 row
341 }
342 } else {
343 row
344 }
345 }
346 }
347 }
348
349 private func reviewersSection(_ mr: MRDetail) -> some View {
350 Section("Reviewers") {
351 RemovableChips(items: mr.reviewRequests, idPrefix: "mr-reviewer",
352 disabled: model.working,
353 remove: { user in Task { await model.removeReviewRequest(user) } })
354 HStack {
355 TextField("Request review", text: $editingReviewer)
356 .autocorrectionDisabled()
357 .textInputAutocapitalization(.never)
358 Button {
359 let user = editingReviewer.trimmingCharacters(in: .whitespaces)
360 editingReviewer = ""
361 Task { await model.requestReview(from: user) }
362 } label: {
363 Image(systemName: "plus.circle.fill")
364 }
365 .disabled(editingReviewer.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
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 .accessibilityIdentifier("mr-comment-field")
542 Picker("Format", selection: $commentFormat) {
543 Text("Markdown").tag("md")
544 Text("Org").tag("org")
545 }
546 .pickerStyle(.segmented)
547 .labelsHidden()
548 .fixedSize()
549 .accessibilityIdentifier("mr-comment-format")
550 Button {
551 let text = commentText
552 commentText = ""
553 Task { await model.comment(text, format: commentFormat) }
554 } label: {
555 Image(systemName: "arrow.up.circle.fill")
556 }
557 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
558 || model.working)
559 }
560 if !commentText.isEmpty {
561 MarkupPreview(text: commentText, format: commentFormat,
562 identifier: "mr-comment-preview")
563 .font(.gbSans(.subheadline))
564 }
565 }
566 }
567
568 @ToolbarContentBuilder
569 private var toolbar: some ToolbarContent {
570 ToolbarItem(placement: .topBarTrailing) {
571 if let mr = model.state.value, mr.isOpen {
572 Menu {
573 Button {
574 draftTitle = mr.title
575 draftBody = mr.body ?? ""
576 draftFormat = mr.bodyFormat ?? "md"
577 editing = true
578 } label: {
579 Label("Edit", systemImage: "pencil")
580 }
581 Divider()
582 Button {
583 Task { await model.review(.approve) }
584 } label: {
585 Label("Approve", systemImage: "checkmark.circle")
586 }
587 Button {
588 Task { await model.review(.requestChanges) }
589 } label: {
590 Label("Request Changes", systemImage: "exclamationmark.circle")
591 }
592 Divider()
593 Button {
594 Task { await model.setDraft(!mr.draft) }
595 } label: {
596 Label(mr.draft ? "Mark as ready" : "Mark as draft",
597 systemImage: mr.draft ? "checkmark.circle" : "pencil.circle")
598 }
599 Button {
600 retargeting = true
601 } label: {
602 Label("Retarget", systemImage: "arrow.triangle.branch")
603 }
604 Divider()
605 Button {
606 confirmingMerge = true
607 } label: {
608 Label(mr.draft ? "Merge (mark as ready first)" : "Merge",
609 systemImage: "arrow.triangle.merge")
610 }
611 .disabled(mr.draft)
612 Button(role: .destructive) {
613 confirmingClose = true
614 } label: {
615 Label("Close", systemImage: "xmark.circle")
616 }
617 } label: {
618 if model.working {
619 ProgressView()
620 } else {
621 Image(systemName: "ellipsis.circle")
622 }
623 }
624 .disabled(model.working)
625 .accessibilityIdentifier("mr-actions-menu")
626 }
627 }
628 }
629
630 private func checkIcon(_ state: String) -> String {
631 switch state {
632 case "success": "checkmark.circle.fill"
633 case "failure", "error": "xmark.circle.fill"
634 case "pending", "running": "circle.dotted"
635 default: "questionmark.circle"
636 }
637 }
638
639 private func checkColor(_ state: String) -> Color {
640 switch state {
641 case "success": .gbOK
642 case "failure", "error": .gbBad
643 case "pending", "running": .gbWarn
644 default: .secondary
645 }
646 }
647}
648
649/// One review thread: anchor, comments, reply, resolve.
650struct ReviewThreadView: View {
651
652 let thread: ReviewThread
653 let model: MRDetailViewModel
654 @State private var replyText = ""
655
656 var body: some View {
657 VStack(alignment: .leading, spacing: 6) {
658 HStack(spacing: 6) {
659 Image(systemName: thread.isResolved
660 ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
661 .font(.gbSans(.caption))
662 .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn)
663 Text("\(thread.path):\(thread.line)")
664 .font(.gbMono(.caption))
665 .lineLimit(1)
666 if thread.stale {
667 GBChip("stale", .gbWarn)
668 }
669 Spacer()
670 Button(thread.isResolved ? "Reopen" : "Resolve") {
671 Task { await model.setResolved(thread, !thread.isResolved) }
672 }
673 .font(.gbSans(.caption))
674 .buttonStyle(.bordered)
675 .disabled(model.working)
676 }
677 ForEach(thread.comments) { comment in
678 VStack(alignment: .leading, spacing: 2) {
679 HStack {
680 Text(comment.author)
681 .font(.gbSans(.caption).weight(.semibold))
682 Text(comment.createdAt, format: .relative(presentation: .named))
683 .font(.gbSans(.caption2))
684 .foregroundStyle(.tertiary)
685 }
686 Text(comment.body)
687 .font(.gbSans(.subheadline))
688 }
689 }
690 if !thread.isResolved {
691 HStack {
692 TextField("Reply", text: $replyText, axis: .vertical)
693 .font(.gbSans(.subheadline))
694 .lineLimit(1...4)
695 Button {
696 let text = replyText
697 replyText = ""
698 Task { await model.reply(to: thread, text) }
699 } label: {
700 Image(systemName: "arrow.up.circle.fill")
701 }
702 .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
703 || model.working)
704 }
705 }
706 }
707 .padding(.vertical, 4)
708 }
709}
710
711/// A single-purpose branch picker for retarget. `model.branches` is
712/// `nil` while loading and `model.branchesError` distinguishes a failed
713/// `repo refs` fetch from a repository that genuinely has no other
714/// branches — an empty list must not read as a failed load.
715/// The repository's other open merge requests, one of which will be
716/// recorded as replacing this one.
717private struct SupersedeSheet: View {
718
719 let model: MRDetailViewModel
720 let onPick: (Int64) -> Void
721
722 @Environment(\.dismiss) private var dismiss
723
724 var body: some View {
725 NavigationStack {
726 Group {
727 if let error = model.openMergeRequestsError {
728 ContentUnavailableView {
729 Label("Could not load merge requests", systemImage: "wifi.exclamationmark")
730 } description: {
731 Text(error)
732 }
733 } else if let candidates = model.openMergeRequests {
734 if candidates.isEmpty {
735 ContentUnavailableView {
736 Label("No other open merge requests", systemImage: "arrow.triangle.pull")
737 } description: {
738 Text("Only an open merge request in this repository can supersede this one.")
739 }
740 } else {
741 List(candidates) { mr in
742 Button {
743 onPick(mr.number)
744 } label: {
745 MRRow(mr: mr)
746 }
747 .tint(.primary)
748 }
749 }
750 } else {
751 ProgressView()
752 }
753 }
754 .navigationTitle("Close !\(model.number) in favour of")
755 .navigationBarTitleDisplayMode(.inline)
756 .toolbar {
757 ToolbarItem(placement: .cancellationAction) {
758 Button("Cancel") { dismiss() }
759 }
760 }
761 .task { await model.loadOpenMergeRequests() }
762 }
763 }
764}
765
766private struct RetargetSheet: View {
767
768 let model: MRDetailViewModel
769 let onPick: (String) -> Void
770
771 @Environment(\.dismiss) private var dismiss
772
773 var body: some View {
774 NavigationStack {
775 Group {
776 if let error = model.branchesError {
777 ContentUnavailableView {
778 Label("Could not load branches", systemImage: "wifi.exclamationmark")
779 } description: {
780 Text(error)
781 }
782 } else if let branches = model.branches {
783 if branches.isEmpty {
784 ContentUnavailableView {
785 Label("No other branches", systemImage: "arrow.triangle.branch")
786 } description: {
787 Text("This repository has no other branches to retarget to.")
788 }
789 } else {
790 List(branches, id: \.self) { branch in
791 Button(branch) { onPick(branch) }
792 }
793 }
794 } else {
795 ProgressView()
796 }
797 }
798 .navigationTitle("Retarget !\(model.number)")
799 .navigationBarTitleDisplayMode(.inline)
800 .toolbar {
801 ToolbarItem(placement: .cancellationAction) {
802 Button("Cancel") { dismiss() }
803 }
804 }
805 .task { await model.loadBranches() }
806 }
807 }
808}