gitbay/Views/MRs/MRView.swift

655 lines · 25851 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 retargeting = false
 16    @State private var pendingRetarget: String?
 17
 18    init(client: GitbayClient, repo: String, number: Int64) {
 19        _model = State(initialValue: MRDetailViewModel(
 20            client: client, repoPath: repo, number: number
 21        ))
 22    }
 23
 24    var body: some View {
 25        List {
 26            if let mr = model.state.value {
 27                header(mr)
 28
 29                if let error = model.actionError {
 30                    Section {
 31                        GBNotice(error, .gbWarn)
 32                    }
 33                }
 34
 35                if let body = mr.body, !body.isEmpty {
 36                    Section {
 37                        FormattedBodyView(text: body, format: mr.bodyFormat)
 38                            .padding(.vertical, 4)
 39                    }
 40                }
 41
 42                milestoneSection(mr)
 43                stackSection(mr)
 44                reviewersSection(mr)
 45                diffSection
 46                if !model.revisions.isEmpty {
 47                    revisionsSection
 48                }
 49
 50                if let commits = mr.commits, !commits.isEmpty {
 51                    commitsSection(commits)
 52                }
 53                if let checks = mr.checks, !checks.isEmpty {
 54                    checksSection(checks, combined: mr.checksCombined)
 55                }
 56                if let reviews = mr.reviews, !reviews.isEmpty {
 57                    reviewsSection(reviews)
 58                }
 59                if !model.threads.isEmpty {
 60                    threadsSection
 61                }
 62                commentsSection(mr.comments ?? [])
 63            }
 64        }
 65        .overlay { LoadStateOverlay(state: model.state) }
 66        .navigationTitle("!\(model.number)")
 67        .navigationBarTitleDisplayMode(.inline)
 68        .toolbar { toolbar }
 69        .task { await model.load() }
 70        .refreshable { await model.load() }
 71        .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
 72            if let mr = model.state.value, !mr.stacked.isEmpty {
 73                Button("Merge") { Task { await model.merge() } }
 74                Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
 75            } else {
 76                Button("Merge") { Task { await model.merge() } }
 77                Button("Squash") { Task { await model.merge(strategy: "squash") } }
 78                Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
 79                Button("Rebase") { Task { await model.merge(strategy: "rebase") } }
 80            }
 81            Button("Cancel", role: .cancel) {}
 82        } message: {
 83            if let mr = model.state.value, !mr.stacked.isEmpty {
 84                Text("Merge requests are stacked on this one. Squash and rebase are unavailable — they would rewrite the commits the stack builds on.")
 85            } else {
 86                Text("The server enforces approvals, threads and checks — a refusal will say why.")
 87            }
 88        }
 89        .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
 90            Button("Close", role: .destructive) { Task { await model.close() } }
 91            Button("Cancel", role: .cancel) {}
 92        }
 93        .confirmationDialog(
 94            "Retarget to \(pendingRetarget ?? "")?",
 95            isPresented: .init(
 96                get: { pendingRetarget != nil },
 97                set: { if !$0 { pendingRetarget = nil } }
 98            ),
 99            titleVisibility: .visible
100        ) {
101            Button("Retarget") {
102                guard let branch = pendingRetarget else { return }
103                pendingRetarget = nil
104                Task { await model.retarget(to: branch) }
105            }
106            Button("Cancel", role: .cancel) { pendingRetarget = nil }
107        } message: {
108            Text("Existing approvals were of the diff against the old target and will no longer apply.")
109        }
110        .sheet(isPresented: $retargeting) {
111            RetargetSheet(model: model) { branch in
112                retargeting = false
113                pendingRetarget = branch
114            }
115        }
116        .sheet(isPresented: $editing) {
117            ComposeSheet(
118                heading: "Edit !\(model.number)",
119                submitLabel: "Save",
120                working: model.working,
121                errorMessage: model.actionError,
122                title: $draftTitle,
123                bodyText: $draftBody,
124                format: $draftFormat
125            ) {
126                Task {
127                    await model.edit(title: draftTitle, body: draftBody, format: draftFormat)
128                    if model.actionError == nil { editing = false }
129                }
130            }
131        }
132    }
133
134    // MARK: - Sections
135
136    @ViewBuilder
137    private func header(_ mr: MRDetail) -> some View {
138        Section {
139            VStack(alignment: .leading, spacing: 6) {
140                Text(mr.title)
141                    .font(.gbSans(.headline))
142                HStack(spacing: 6) {
143                    MRStateBadge(state: mr.state)
144                    if mr.draft {
145                        GBChip("draft", .secondary)
146                    }
147                    Text(mr.source.isEmpty ? "(source gone)" : mr.source)
148                        .lineLimit(1)
149                    Image(systemName: "arrow.right")
150                        .font(.gbSans(.caption2))
151                    Text(mr.targetRef)
152                }
153                .font(.gbSans(.caption))
154                .foregroundStyle(.secondary)
155                HStack(spacing: 6) {
156                    Text("by \(mr.author)")
157                    Text(mr.createdAt, format: .relative(presentation: .named))
158                        .foregroundStyle(.tertiary)
159                }
160                .font(.gbSans(.caption))
161                .foregroundStyle(.secondary)
162                resolution(mr)
163            }
164            .padding(.vertical, 2)
165        }
166    }
167
168    /// What became of the merge request, once it is no longer open. The
169    /// actor is absent for imported rows, which carry only a time.
170    @ViewBuilder
171    private func resolution(_ mr: MRDetail) -> some View {
172        if let at = mr.mergedAt {
173            stampLine("merged", by: mr.mergedBy, at: at)
174        } else if let at = mr.closedAt {
175            stampLine("closed without merging", by: mr.closedBy, at: at)
176        }
177    }
178
179    private func stampLine(_ verb: String, by actor: String?, at: Date) -> some View {
180        HStack(spacing: 6) {
181            Text(actor.map { "\(verb) by \($0)" } ?? verb)
182            Text(at, format: Self.stamp)
183                .foregroundStyle(.tertiary)
184        }
185        .font(.gbSans(.caption))
186        .foregroundStyle(.secondary)
187    }
188
189    /// Reviews and checks are read for when they happened, so they carry
190    /// the date rather than "3 days ago".
191    private static let stamp = Date.FormatStyle.dateTime
192        .year().month(.abbreviated).day().hour().minute()
193
194    private func milestoneSection(_ mr: MRDetail) -> some View {
195        Section("Milestone") {
196            Menu {
197                Button("None") { Task { await model.setMilestone(nil) } }
198                ForEach(model.availableMilestones ?? []) { milestone in
199                    Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") {
200                        Task { await model.setMilestone(milestone.title) }
201                    }
202                }
203            } label: {
204                HStack {
205                    Label(mr.milestone ?? "None", systemImage: "flag")
206                        .font(.gbSans(.subheadline))
207                    Spacer()
208                    Image(systemName: "chevron.up.chevron.down")
209                        .font(.gbSans(.caption2))
210                        .foregroundStyle(.secondary)
211                }
212            }
213            .disabled(model.working)
214            .task { await model.loadMilestones() }
215            .accessibilityIdentifier("mr-milestone-menu")
216        }
217    }
218
219    /// Shown only when this merge request is part of a stack. Each row
220    /// spells out its own direction — "stacked on" vs. "is stacked on
221    /// this" mean opposite things and look alike if abbreviated.
222    @ViewBuilder
223    private func stackSection(_ mr: MRDetail) -> some View {
224        if mr.stackedOn != nil || !mr.stacked.isEmpty {
225            Section("Stack") {
226                if let base = mr.stackedOn {
227                    NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: base.number)) {
228                        Label("stacked on !\(base.number) — \(base.title)", systemImage: "arrow.up.to.line")
229                            .font(.gbSans(.subheadline))
230                            .lineLimit(1)
231                    }
232                }
233                ForEach(mr.stacked) { ref in
234                    NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: ref.number)) {
235                        Label("!\(ref.number) is stacked on this — \(ref.title)", systemImage: "arrow.down.to.line")
236                            .font(.gbSans(.subheadline))
237                            .lineLimit(1)
238                    }
239                }
240            }
241        }
242    }
243
244    /// Every head the merge request has had. A revision after the first
245    /// links to the range-diff from the one before it.
246    private var revisionsSection: some View {
247        Section("Revisions") {
248            ForEach(Array(model.revisions.enumerated()), id: \.element.id) { index, revision in
249                let row = HStack {
250                    Text("v\(revision.n)")
251                        .font(.gbSans(.subheadline).weight(.medium))
252                    Text(String(revision.sha.prefix(10)))
253                        .font(.gbMono(.caption))
254                        .foregroundStyle(.secondary)
255                    Spacer()
256                    if revision.isCurrent {
257                        GBChip("current", .secondary)
258                    }
259                    Text(revision.createdAt.prefix(10))
260                        .font(.gbSans(.caption))
261                        .foregroundStyle(.tertiary)
262                }
263                if index > 0 {
264                    NavigationLink(value: MRRoute.rangeDiff(
265                        repo: model.repoPath, number: model.number,
266                        from: model.revisions[index - 1].sha, to: revision.sha)
267                    ) {
268                        row
269                    }
270                } else {
271                    row
272                }
273            }
274        }
275    }
276
277    private func reviewersSection(_ mr: MRDetail) -> some View {
278        Section("Reviewers") {
279            reviewerFlow(mr.reviewRequests)
280            HStack {
281                TextField("Request review", text: $editingReviewer)
282                    .autocorrectionDisabled()
283                    .textInputAutocapitalization(.never)
284                Button {
285                    let user = editingReviewer.trimmingCharacters(in: .whitespaces)
286                    editingReviewer = ""
287                    Task { await model.requestReview(from: user) }
288                } label: {
289                    Image(systemName: "plus.circle.fill")
290                }
291                .disabled(editingReviewer.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
292            }
293        }
294    }
295
296    /// The same add/remove chip shape `IssueView` uses for labels and
297    /// assignees.
298    @ViewBuilder
299    private func reviewerFlow(_ items: [String]) -> some View {
300        if !items.isEmpty {
301            ScrollView(.horizontal, showsIndicators: false) {
302                HStack(spacing: 6) {
303                    ForEach(items, id: \.self) { item in
304                        HStack(spacing: 3) {
305                            Text(item)
306                            Button {
307                                Task { await model.removeReviewRequest(item) }
308                            } label: {
309                                Image(systemName: "xmark.circle.fill")
310                                    .foregroundStyle(.tertiary)
311                            }
312                            .disabled(model.working)
313                        }
314                        .font(.gbSans(.caption))
315                        .padding(.horizontal, 8)
316                        .padding(.vertical, 3)
317                        .background(Color.secondary.opacity(0.07), in: gbChipShape)
318                        .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1))
319                    }
320                }
321            }
322        }
323    }
324
325    private var diffSection: some View {
326        Section {
327            NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) {
328                HStack {
329                    Label("Diff", systemImage: "plus.forwardslash.minus")
330                    Spacer()
331                    if let diff = model.diff {
332                        Text("+\(diff.additions)")
333                            .foregroundStyle(Color.gbOK)
334                        Text("−\(diff.deletions)")
335                            .foregroundStyle(Color.gbBad)
336                    }
337                }
338                .font(.gbSans(.subheadline))
339            }
340        }
341    }
342
343    private func commitsSection(_ commits: [MRDetail.MRCommit]) -> some View {
344        Section("Commits") {
345            ForEach(commits) { commit in
346                HStack(spacing: 8) {
347                    Text(commit.shortSHA)
348                        .font(.gbMono(.caption))
349                        .foregroundStyle(.secondary)
350                    Text(commit.subject)
351                        .font(.gbSans(.subheadline))
352                        .lineLimit(1)
353                }
354            }
355        }
356    }
357
358    private func checksSection(_ checks: [MRDetail.Check], combined: String?) -> some View {
359        Section("Checks" + (combined.map { " — \($0)" } ?? "")) {
360            ForEach(checks, id: \.context) { check in
361                VStack(alignment: .leading, spacing: 2) {
362                    HStack {
363                        Image(systemName: checkIcon(check.state))
364                            .foregroundStyle(checkColor(check.state))
365                        Text(check.context)
366                            .font(.gbSans(.subheadline))
367                        Spacer()
368                        Text(check.state)
369                            .font(.gbSans(.caption))
370                            .foregroundStyle(.secondary)
371                    }
372                    timing(check.updatedAt, duration: check.duration)
373                }
374            }
375        }
376    }
377
378    private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View {
379        Section("Reviews") {
380            ForEach(reviews) { review in
381                VStack(alignment: .leading, spacing: 2) {
382                    HStack {
383                        Image(systemName: review.verdict == "approve"
384                            ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
385                            .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn)
386                        Text(review.reviewer)
387                            .font(.gbSans(.subheadline))
388                        Spacer()
389                        if review.stale {
390                            GBChip("stale", .gbWarn)
391                        }
392                    }
393                    timing(review.createdAt)
394                }
395            }
396        }
397    }
398
399    /// When a review or check last said something, and for a CI check how
400    /// long its build ran. Nothing renders against a server that does not
401    /// report the time.
402    @ViewBuilder
403    private func timing(_ at: Date?, duration: String? = nil) -> some View {
404        if let at {
405            HStack(spacing: 6) {
406                Text(at, format: Self.stamp)
407                if let duration {
408                    Text("·")
409                    Text(duration)
410                }
411            }
412            .font(.gbSans(.caption))
413            .foregroundStyle(.tertiary)
414        }
415    }
416
417    private var threadsSection: some View {
418        Section("Threads — \(model.unresolvedCount) unresolved") {
419            ForEach(model.threads) { thread in
420                ReviewThreadView(thread: thread, model: model)
421            }
422        }
423    }
424
425    private func commentsSection(_ comments: [MRDetail.MRComment]) -> some View {
426        Section("Comments") {
427            ForEach(comments) { comment in
428                VStack(alignment: .leading, spacing: 4) {
429                    HStack {
430                        Text(comment.author)
431                            .font(.gbSans(.caption).weight(.semibold))
432                        Text(comment.createdAt, format: .relative(presentation: .named))
433                            .font(.gbSans(.caption))
434                            .foregroundStyle(.tertiary)
435                    }
436                    FormattedBodyView(text: comment.body, format: comment.bodyFormat)
437                        .font(.gbSans(.subheadline))
438                }
439                .padding(.vertical, 2)
440            }
441
442            HStack {
443                TextField("Comment", text: $commentText, axis: .vertical)
444                    .lineLimit(1...5)
445                Picker("Format", selection: $commentFormat) {
446                    Text("Markdown").tag("md")
447                    Text("Org").tag("org")
448                }
449                .pickerStyle(.segmented)
450                .labelsHidden()
451                .fixedSize()
452                .accessibilityIdentifier("mr-comment-format")
453                Button {
454                    let text = commentText
455                    commentText = ""
456                    Task { await model.comment(text, format: commentFormat) }
457                } label: {
458                    Image(systemName: "arrow.up.circle.fill")
459                }
460                .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
461                    || model.working)
462            }
463        }
464    }
465
466    @ToolbarContentBuilder
467    private var toolbar: some ToolbarContent {
468        ToolbarItem(placement: .topBarTrailing) {
469            if let mr = model.state.value, mr.isOpen {
470                Menu {
471                    Button {
472                        draftTitle = mr.title
473                        draftBody = mr.body ?? ""
474                        draftFormat = mr.bodyFormat ?? "md"
475                        editing = true
476                    } label: {
477                        Label("Edit", systemImage: "pencil")
478                    }
479                    Divider()
480                    Button {
481                        Task { await model.review(.approve) }
482                    } label: {
483                        Label("Approve", systemImage: "checkmark.circle")
484                    }
485                    Button {
486                        Task { await model.review(.requestChanges) }
487                    } label: {
488                        Label("Request Changes", systemImage: "exclamationmark.circle")
489                    }
490                    Divider()
491                    Button {
492                        Task { await model.setDraft(!mr.draft) }
493                    } label: {
494                        Label(mr.draft ? "Mark as ready" : "Mark as draft",
495                              systemImage: mr.draft ? "checkmark.circle" : "pencil.circle")
496                    }
497                    Button {
498                        retargeting = true
499                    } label: {
500                        Label("Retarget", systemImage: "arrow.triangle.branch")
501                    }
502                    Divider()
503                    Button {
504                        confirmingMerge = true
505                    } label: {
506                        Label(mr.draft ? "Merge (mark as ready first)" : "Merge",
507                              systemImage: "arrow.triangle.merge")
508                    }
509                    .disabled(mr.draft)
510                    Button(role: .destructive) {
511                        confirmingClose = true
512                    } label: {
513                        Label("Close", systemImage: "xmark.circle")
514                    }
515                } label: {
516                    if model.working {
517                        ProgressView()
518                    } else {
519                        Image(systemName: "ellipsis.circle")
520                    }
521                }
522                .disabled(model.working)
523                .accessibilityIdentifier("mr-actions-menu")
524            }
525        }
526    }
527
528    private func checkIcon(_ state: String) -> String {
529        switch state {
530        case "success": "checkmark.circle.fill"
531        case "failure", "error": "xmark.circle.fill"
532        case "pending", "running": "circle.dotted"
533        default: "questionmark.circle"
534        }
535    }
536
537    private func checkColor(_ state: String) -> Color {
538        switch state {
539        case "success": .gbOK
540        case "failure", "error": .gbBad
541        case "pending", "running": .gbWarn
542        default: .secondary
543        }
544    }
545}
546
547/// One review thread: anchor, comments, reply, resolve.
548struct ReviewThreadView: View {
549
550    let thread: ReviewThread
551    let model: MRDetailViewModel
552    @State private var replyText = ""
553
554    var body: some View {
555        VStack(alignment: .leading, spacing: 6) {
556            HStack(spacing: 6) {
557                Image(systemName: thread.isResolved
558                    ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
559                    .font(.gbSans(.caption))
560                    .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn)
561                Text("\(thread.path):\(thread.line)")
562                    .font(.gbMono(.caption))
563                    .lineLimit(1)
564                if thread.stale {
565                    GBChip("stale", .gbWarn)
566                }
567                Spacer()
568                Button(thread.isResolved ? "Reopen" : "Resolve") {
569                    Task { await model.setResolved(thread, !thread.isResolved) }
570                }
571                .font(.gbSans(.caption))
572                .buttonStyle(.bordered)
573                .disabled(model.working)
574            }
575            ForEach(thread.comments) { comment in
576                VStack(alignment: .leading, spacing: 2) {
577                    HStack {
578                        Text(comment.author)
579                            .font(.gbSans(.caption).weight(.semibold))
580                        Text(comment.createdAt, format: .relative(presentation: .named))
581                            .font(.gbSans(.caption2))
582                            .foregroundStyle(.tertiary)
583                    }
584                    Text(comment.body)
585                        .font(.gbSans(.subheadline))
586                }
587            }
588            if !thread.isResolved {
589                HStack {
590                    TextField("Reply", text: $replyText, axis: .vertical)
591                        .font(.gbSans(.subheadline))
592                        .lineLimit(1...4)
593                    Button {
594                        let text = replyText
595                        replyText = ""
596                        Task { await model.reply(to: thread, text) }
597                    } label: {
598                        Image(systemName: "arrow.up.circle.fill")
599                    }
600                    .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
601                        || model.working)
602                }
603            }
604        }
605        .padding(.vertical, 4)
606    }
607}
608
609/// A single-purpose branch picker for retarget. `model.branches` is
610/// `nil` while loading and `model.branchesError` distinguishes a failed
611/// `repo refs` fetch from a repository that genuinely has no other
612/// branches — an empty list must not read as a failed load.
613private struct RetargetSheet: View {
614
615    let model: MRDetailViewModel
616    let onPick: (String) -> Void
617
618    @Environment(\.dismiss) private var dismiss
619
620    var body: some View {
621        NavigationStack {
622            Group {
623                if let error = model.branchesError {
624                    ContentUnavailableView {
625                        Label("Could not load branches", systemImage: "wifi.exclamationmark")
626                    } description: {
627                        Text(error)
628                    }
629                } else if let branches = model.branches {
630                    if branches.isEmpty {
631                        ContentUnavailableView {
632                            Label("No other branches", systemImage: "arrow.triangle.branch")
633                        } description: {
634                            Text("This repository has no other branches to retarget to.")
635                        }
636                    } else {
637                        List(branches, id: \.self) { branch in
638                            Button(branch) { onPick(branch) }
639                        }
640                    }
641                } else {
642                    ProgressView()
643                }
644            }
645            .navigationTitle("Retarget !\(model.number)")
646            .navigationBarTitleDisplayMode(.inline)
647            .toolbar {
648                ToolbarItem(placement: .cancellationAction) {
649                    Button("Cancel") { dismiss() }
650                }
651            }
652            .task { await model.loadBranches() }
653        }
654    }
655}