gitbay/Views/MRs/MRView.swift
432 lines · 16339 bytes
1import SwiftUI
2
3struct MRView: View {
4
5 @State private var model: MRDetailViewModel
6 @State private var commentText = ""
7 @State private var confirmingMerge = false
8 @State private var confirmingClose = false
9 @State private var editing = false
10 @State private var draftTitle = ""
11 @State private var draftBody = ""
12
13 init(client: GitbayClient, repo: String, number: Int64) {
14 _model = State(initialValue: MRDetailViewModel(
15 client: client, repoPath: repo, number: number
16 ))
17 }
18
19 var body: some View {
20 List {
21 if let mr = model.state.value {
22 header(mr)
23
24 if let error = model.actionError {
25 Section {
26 GBNotice(error, .gbWarn)
27 }
28 }
29
30 if let body = mr.body, !body.isEmpty {
31 Section {
32 MarkdownView(markdown: body)
33 .padding(.vertical, 4)
34 }
35 }
36
37 milestoneSection(mr)
38 diffSection
39
40 if let commits = mr.commits, !commits.isEmpty {
41 commitsSection(commits)
42 }
43 if let checks = mr.checks, !checks.isEmpty {
44 checksSection(checks, combined: mr.checksCombined)
45 }
46 if let reviews = mr.reviews, !reviews.isEmpty {
47 reviewsSection(reviews)
48 }
49 if !model.threads.isEmpty {
50 threadsSection
51 }
52 commentsSection(mr.comments ?? [])
53 }
54 }
55 .overlay { LoadStateOverlay(state: model.state) }
56 .navigationTitle("!\(model.number)")
57 .navigationBarTitleDisplayMode(.inline)
58 .toolbar { toolbar }
59 .task { await model.load() }
60 .refreshable { await model.load() }
61 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
62 Button("Merge") { Task { await model.merge() } }
63 Button("Squash") { Task { await model.merge(strategy: "squash") } }
64 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
65 Button("Rebase") { Task { await model.merge(strategy: "rebase") } }
66 Button("Cancel", role: .cancel) {}
67 } message: {
68 Text("The server enforces approvals, threads and checks — a refusal will say why.")
69 }
70 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
71 Button("Close", role: .destructive) { Task { await model.close() } }
72 Button("Cancel", role: .cancel) {}
73 }
74 .sheet(isPresented: $editing) {
75 ComposeSheet(
76 heading: "Edit !\(model.number)",
77 submitLabel: "Save",
78 working: model.working,
79 errorMessage: model.actionError,
80 title: $draftTitle,
81 bodyText: $draftBody
82 ) {
83 Task {
84 await model.edit(title: draftTitle, body: draftBody)
85 if model.actionError == nil { editing = false }
86 }
87 }
88 }
89 }
90
91 // MARK: - Sections
92
93 @ViewBuilder
94 private func header(_ mr: MRDetail) -> some View {
95 Section {
96 VStack(alignment: .leading, spacing: 6) {
97 Text(mr.title)
98 .font(.gbSans(.headline))
99 HStack(spacing: 6) {
100 MRStateBadge(state: mr.state)
101 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
102 .lineLimit(1)
103 Image(systemName: "arrow.right")
104 .font(.gbSans(.caption2))
105 Text(mr.targetRef)
106 }
107 .font(.gbSans(.caption))
108 .foregroundStyle(.secondary)
109 HStack(spacing: 6) {
110 Text("by \(mr.author)")
111 Text(mr.createdAt, format: .relative(presentation: .named))
112 .foregroundStyle(.tertiary)
113 }
114 .font(.gbSans(.caption))
115 .foregroundStyle(.secondary)
116 resolution(mr)
117 }
118 .padding(.vertical, 2)
119 }
120 }
121
122 /// What became of the merge request, once it is no longer open. The
123 /// actor is absent for imported rows, which carry only a time.
124 @ViewBuilder
125 private func resolution(_ mr: MRDetail) -> some View {
126 if let at = mr.mergedAt {
127 stampLine("merged", by: mr.mergedBy, at: at)
128 } else if let at = mr.closedAt {
129 stampLine("closed without merging", by: mr.closedBy, at: at)
130 }
131 }
132
133 private func stampLine(_ verb: String, by actor: String?, at: Date) -> some View {
134 HStack(spacing: 6) {
135 Text(actor.map { "\(verb) by \($0)" } ?? verb)
136 Text(at, format: Self.stamp)
137 .foregroundStyle(.tertiary)
138 }
139 .font(.gbSans(.caption))
140 .foregroundStyle(.secondary)
141 }
142
143 /// Reviews and checks are read for when they happened, so they carry
144 /// the date rather than "3 days ago".
145 private static let stamp = Date.FormatStyle.dateTime
146 .year().month(.abbreviated).day().hour().minute()
147
148 private func milestoneSection(_ mr: MRDetail) -> some View {
149 Section("Milestone") {
150 Menu {
151 Button("None") { Task { await model.setMilestone(nil) } }
152 ForEach(model.availableMilestones ?? []) { milestone in
153 Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") {
154 Task { await model.setMilestone(milestone.title) }
155 }
156 }
157 } label: {
158 HStack {
159 Label(mr.milestone ?? "None", systemImage: "flag")
160 .font(.gbSans(.subheadline))
161 Spacer()
162 Image(systemName: "chevron.up.chevron.down")
163 .font(.gbSans(.caption2))
164 .foregroundStyle(.secondary)
165 }
166 }
167 .disabled(model.working)
168 .task { await model.loadMilestones() }
169 .accessibilityIdentifier("mr-milestone-menu")
170 }
171 }
172
173 private var diffSection: some View {
174 Section {
175 NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) {
176 HStack {
177 Label("Diff", systemImage: "plus.forwardslash.minus")
178 Spacer()
179 if let diff = model.diff {
180 Text("+\(diff.additions)")
181 .foregroundStyle(Color.gbOK)
182 Text("−\(diff.deletions)")
183 .foregroundStyle(Color.gbBad)
184 }
185 }
186 .font(.gbSans(.subheadline))
187 }
188 }
189 }
190
191 private func commitsSection(_ commits: [MRDetail.MRCommit]) -> some View {
192 Section("Commits") {
193 ForEach(commits) { commit in
194 HStack(spacing: 8) {
195 Text(commit.shortSHA)
196 .font(.gbMono(.caption))
197 .foregroundStyle(.secondary)
198 Text(commit.subject)
199 .font(.gbSans(.subheadline))
200 .lineLimit(1)
201 }
202 }
203 }
204 }
205
206 private func checksSection(_ checks: [MRDetail.Check], combined: String?) -> some View {
207 Section("Checks" + (combined.map { " — \($0)" } ?? "")) {
208 ForEach(checks, id: \.context) { check in
209 VStack(alignment: .leading, spacing: 2) {
210 HStack {
211 Image(systemName: checkIcon(check.state))
212 .foregroundStyle(checkColor(check.state))
213 Text(check.context)
214 .font(.gbSans(.subheadline))
215 Spacer()
216 Text(check.state)
217 .font(.gbSans(.caption))
218 .foregroundStyle(.secondary)
219 }
220 timing(check.updatedAt, duration: check.duration)
221 }
222 }
223 }
224 }
225
226 private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View {
227 Section("Reviews") {
228 ForEach(reviews) { review in
229 VStack(alignment: .leading, spacing: 2) {
230 HStack {
231 Image(systemName: review.verdict == "approve"
232 ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
233 .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn)
234 Text(review.reviewer)
235 .font(.gbSans(.subheadline))
236 Spacer()
237 if review.stale {
238 GBChip("stale", .gbWarn)
239 }
240 }
241 timing(review.createdAt)
242 }
243 }
244 }
245 }
246
247 /// When a review or check last said something, and for a CI check how
248 /// long its build ran. Nothing renders against a server that does not
249 /// report the time.
250 @ViewBuilder
251 private func timing(_ at: Date?, duration: String? = nil) -> some View {
252 if let at {
253 HStack(spacing: 6) {
254 Text(at, format: Self.stamp)
255 if let duration {
256 Text("·")
257 Text(duration)
258 }
259 }
260 .font(.gbSans(.caption))
261 .foregroundStyle(.tertiary)
262 }
263 }
264
265 private var threadsSection: some View {
266 Section("Threads — \(model.unresolvedCount) unresolved") {
267 ForEach(model.threads) { thread in
268 ReviewThreadView(thread: thread, model: model)
269 }
270 }
271 }
272
273 private func commentsSection(_ comments: [MRDetail.MRComment]) -> some View {
274 Section("Comments") {
275 ForEach(comments) { comment in
276 VStack(alignment: .leading, spacing: 4) {
277 HStack {
278 Text(comment.author)
279 .font(.gbSans(.caption).weight(.semibold))
280 Text(comment.createdAt, format: .relative(presentation: .named))
281 .font(.gbSans(.caption))
282 .foregroundStyle(.tertiary)
283 }
284 MarkdownView(markdown: comment.body)
285 .font(.gbSans(.subheadline))
286 }
287 .padding(.vertical, 2)
288 }
289
290 HStack {
291 TextField("Comment", text: $commentText, axis: .vertical)
292 .lineLimit(1...5)
293 Button {
294 let text = commentText
295 commentText = ""
296 Task { await model.comment(text) }
297 } label: {
298 Image(systemName: "arrow.up.circle.fill")
299 }
300 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
301 || model.working)
302 }
303 }
304 }
305
306 @ToolbarContentBuilder
307 private var toolbar: some ToolbarContent {
308 ToolbarItem(placement: .topBarTrailing) {
309 if let mr = model.state.value, mr.isOpen {
310 Menu {
311 Button {
312 draftTitle = mr.title
313 draftBody = mr.body ?? ""
314 editing = true
315 } label: {
316 Label("Edit", systemImage: "pencil")
317 }
318 Divider()
319 Button {
320 Task { await model.review(.approve) }
321 } label: {
322 Label("Approve", systemImage: "checkmark.circle")
323 }
324 Button {
325 Task { await model.review(.requestChanges) }
326 } label: {
327 Label("Request Changes", systemImage: "exclamationmark.circle")
328 }
329 Divider()
330 Button {
331 confirmingMerge = true
332 } label: {
333 Label("Merge", systemImage: "arrow.triangle.merge")
334 }
335 Button(role: .destructive) {
336 confirmingClose = true
337 } label: {
338 Label("Close", systemImage: "xmark.circle")
339 }
340 } label: {
341 if model.working {
342 ProgressView()
343 } else {
344 Image(systemName: "ellipsis.circle")
345 }
346 }
347 .disabled(model.working)
348 .accessibilityIdentifier("mr-actions-menu")
349 }
350 }
351 }
352
353 private func checkIcon(_ state: String) -> String {
354 switch state {
355 case "success": "checkmark.circle.fill"
356 case "failure", "error": "xmark.circle.fill"
357 case "pending", "running": "circle.dotted"
358 default: "questionmark.circle"
359 }
360 }
361
362 private func checkColor(_ state: String) -> Color {
363 switch state {
364 case "success": .gbOK
365 case "failure", "error": .gbBad
366 case "pending", "running": .gbWarn
367 default: .secondary
368 }
369 }
370}
371
372/// One review thread: anchor, comments, reply, resolve.
373struct ReviewThreadView: View {
374
375 let thread: ReviewThread
376 let model: MRDetailViewModel
377 @State private var replyText = ""
378
379 var body: some View {
380 VStack(alignment: .leading, spacing: 6) {
381 HStack(spacing: 6) {
382 Image(systemName: thread.isResolved
383 ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
384 .font(.gbSans(.caption))
385 .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn)
386 Text("\(thread.path):\(thread.line)")
387 .font(.gbMono(.caption))
388 .lineLimit(1)
389 if thread.stale {
390 GBChip("stale", .gbWarn)
391 }
392 Spacer()
393 Button(thread.isResolved ? "Reopen" : "Resolve") {
394 Task { await model.setResolved(thread, !thread.isResolved) }
395 }
396 .font(.gbSans(.caption))
397 .buttonStyle(.bordered)
398 .disabled(model.working)
399 }
400 ForEach(thread.comments) { comment in
401 VStack(alignment: .leading, spacing: 2) {
402 HStack {
403 Text(comment.author)
404 .font(.gbSans(.caption).weight(.semibold))
405 Text(comment.createdAt, format: .relative(presentation: .named))
406 .font(.gbSans(.caption2))
407 .foregroundStyle(.tertiary)
408 }
409 Text(comment.body)
410 .font(.gbSans(.subheadline))
411 }
412 }
413 if !thread.isResolved {
414 HStack {
415 TextField("Reply", text: $replyText, axis: .vertical)
416 .font(.gbSans(.subheadline))
417 .lineLimit(1...4)
418 Button {
419 let text = replyText
420 replyText = ""
421 Task { await model.reply(to: thread, text) }
422 } label: {
423 Image(systemName: "arrow.up.circle.fill")
424 }
425 .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
426 || model.working)
427 }
428 }
429 }
430 .padding(.vertical, 4)
431 }
432}