gitbay/Views/MRs/MRView.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Views/MRs/MRView.swift history · blame · raw

909 lines · 37574 bytes

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