gitbay/Views/MRs/MRView.swift

v1.13.1
gitbay-ios/gitbay/Views/MRs/MRView.swift history · blame · raw

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