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}