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}