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}