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