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