gitbay/Views/MRs/MRView.swift

801 lines · 32285 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                Picker("Format", selection: $commentFormat) {
541                    Text("Markdown").tag("md")
542                    Text("Org").tag("org")
543                }
544                .pickerStyle(.segmented)
545                .labelsHidden()
546                .fixedSize()
547                .accessibilityIdentifier("mr-comment-format")
548                Button {
549                    let text = commentText
550                    commentText = ""
551                    Task { await model.comment(text, format: commentFormat) }
552                } label: {
553                    Image(systemName: "arrow.up.circle.fill")
554                }
555                .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
556                    || model.working)
557            }
558        }
559    }
560
561    @ToolbarContentBuilder
562    private var toolbar: some ToolbarContent {
563        ToolbarItem(placement: .topBarTrailing) {
564            if let mr = model.state.value, mr.isOpen {
565                Menu {
566                    Button {
567                        draftTitle = mr.title
568                        draftBody = mr.body ?? ""
569                        draftFormat = mr.bodyFormat ?? "md"
570                        editing = true
571                    } label: {
572                        Label("Edit", systemImage: "pencil")
573                    }
574                    Divider()
575                    Button {
576                        Task { await model.review(.approve) }
577                    } label: {
578                        Label("Approve", systemImage: "checkmark.circle")
579                    }
580                    Button {
581                        Task { await model.review(.requestChanges) }
582                    } label: {
583                        Label("Request Changes", systemImage: "exclamationmark.circle")
584                    }
585                    Divider()
586                    Button {
587                        Task { await model.setDraft(!mr.draft) }
588                    } label: {
589                        Label(mr.draft ? "Mark as ready" : "Mark as draft",
590                              systemImage: mr.draft ? "checkmark.circle" : "pencil.circle")
591                    }
592                    Button {
593                        retargeting = true
594                    } label: {
595                        Label("Retarget", systemImage: "arrow.triangle.branch")
596                    }
597                    Divider()
598                    Button {
599                        confirmingMerge = true
600                    } label: {
601                        Label(mr.draft ? "Merge (mark as ready first)" : "Merge",
602                              systemImage: "arrow.triangle.merge")
603                    }
604                    .disabled(mr.draft)
605                    Button(role: .destructive) {
606                        confirmingClose = true
607                    } label: {
608                        Label("Close", systemImage: "xmark.circle")
609                    }
610                } label: {
611                    if model.working {
612                        ProgressView()
613                    } else {
614                        Image(systemName: "ellipsis.circle")
615                    }
616                }
617                .disabled(model.working)
618                .accessibilityIdentifier("mr-actions-menu")
619            }
620        }
621    }
622
623    private func checkIcon(_ state: String) -> String {
624        switch state {
625        case "success": "checkmark.circle.fill"
626        case "failure", "error": "xmark.circle.fill"
627        case "pending", "running": "circle.dotted"
628        default: "questionmark.circle"
629        }
630    }
631
632    private func checkColor(_ state: String) -> Color {
633        switch state {
634        case "success": .gbOK
635        case "failure", "error": .gbBad
636        case "pending", "running": .gbWarn
637        default: .secondary
638        }
639    }
640}
641
642/// One review thread: anchor, comments, reply, resolve.
643struct ReviewThreadView: View {
644
645    let thread: ReviewThread
646    let model: MRDetailViewModel
647    @State private var replyText = ""
648
649    var body: some View {
650        VStack(alignment: .leading, spacing: 6) {
651            HStack(spacing: 6) {
652                Image(systemName: thread.isResolved
653                    ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
654                    .font(.gbSans(.caption))
655                    .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn)
656                Text("\(thread.path):\(thread.line)")
657                    .font(.gbMono(.caption))
658                    .lineLimit(1)
659                if thread.stale {
660                    GBChip("stale", .gbWarn)
661                }
662                Spacer()
663                Button(thread.isResolved ? "Reopen" : "Resolve") {
664                    Task { await model.setResolved(thread, !thread.isResolved) }
665                }
666                .font(.gbSans(.caption))
667                .buttonStyle(.bordered)
668                .disabled(model.working)
669            }
670            ForEach(thread.comments) { comment in
671                VStack(alignment: .leading, spacing: 2) {
672                    HStack {
673                        Text(comment.author)
674                            .font(.gbSans(.caption).weight(.semibold))
675                        Text(comment.createdAt, format: .relative(presentation: .named))
676                            .font(.gbSans(.caption2))
677                            .foregroundStyle(.tertiary)
678                    }
679                    Text(comment.body)
680                        .font(.gbSans(.subheadline))
681                }
682            }
683            if !thread.isResolved {
684                HStack {
685                    TextField("Reply", text: $replyText, axis: .vertical)
686                        .font(.gbSans(.subheadline))
687                        .lineLimit(1...4)
688                    Button {
689                        let text = replyText
690                        replyText = ""
691                        Task { await model.reply(to: thread, text) }
692                    } label: {
693                        Image(systemName: "arrow.up.circle.fill")
694                    }
695                    .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
696                        || model.working)
697                }
698            }
699        }
700        .padding(.vertical, 4)
701    }
702}
703
704/// A single-purpose branch picker for retarget. `model.branches` is
705/// `nil` while loading and `model.branchesError` distinguishes a failed
706/// `repo refs` fetch from a repository that genuinely has no other
707/// branches — an empty list must not read as a failed load.
708/// The repository's other open merge requests, one of which will be
709/// recorded as replacing this one.
710private struct SupersedeSheet: View {
711
712    let model: MRDetailViewModel
713    let onPick: (Int64) -> Void
714
715    @Environment(\.dismiss) private var dismiss
716
717    var body: some View {
718        NavigationStack {
719            Group {
720                if let error = model.openMergeRequestsError {
721                    ContentUnavailableView {
722                        Label("Could not load merge requests", systemImage: "wifi.exclamationmark")
723                    } description: {
724                        Text(error)
725                    }
726                } else if let candidates = model.openMergeRequests {
727                    if candidates.isEmpty {
728                        ContentUnavailableView {
729                            Label("No other open merge requests", systemImage: "arrow.triangle.pull")
730                        } description: {
731                            Text("Only an open merge request in this repository can supersede this one.")
732                        }
733                    } else {
734                        List(candidates) { mr in
735                            Button {
736                                onPick(mr.number)
737                            } label: {
738                                MRRow(mr: mr)
739                            }
740                            .tint(.primary)
741                        }
742                    }
743                } else {
744                    ProgressView()
745                }
746            }
747            .navigationTitle("Close !\(model.number) in favour of")
748            .navigationBarTitleDisplayMode(.inline)
749            .toolbar {
750                ToolbarItem(placement: .cancellationAction) {
751                    Button("Cancel") { dismiss() }
752                }
753            }
754            .task { await model.loadOpenMergeRequests() }
755        }
756    }
757}
758
759private struct RetargetSheet: View {
760
761    let model: MRDetailViewModel
762    let onPick: (String) -> Void
763
764    @Environment(\.dismiss) private var dismiss
765
766    var body: some View {
767        NavigationStack {
768            Group {
769                if let error = model.branchesError {
770                    ContentUnavailableView {
771                        Label("Could not load branches", systemImage: "wifi.exclamationmark")
772                    } description: {
773                        Text(error)
774                    }
775                } else if let branches = model.branches {
776                    if branches.isEmpty {
777                        ContentUnavailableView {
778                            Label("No other branches", systemImage: "arrow.triangle.branch")
779                        } description: {
780                            Text("This repository has no other branches to retarget to.")
781                        }
782                    } else {
783                        List(branches, id: \.self) { branch in
784                            Button(branch) { onPick(branch) }
785                        }
786                    }
787                } else {
788                    ProgressView()
789                }
790            }
791            .navigationTitle("Retarget !\(model.number)")
792            .navigationBarTitleDisplayMode(.inline)
793            .toolbar {
794                ToolbarItem(placement: .cancellationAction) {
795                    Button("Cancel") { dismiss() }
796                }
797            }
798            .task { await model.loadBranches() }
799        }
800    }
801}