gitbay/Views/MRs/MRView.swift

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