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}