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}