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