a native ios client for gitbay

client ios swift

https://gitbay.org

gitbay/Views/MRs/MRView.swift

381 lines · 14345 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            }
117            .padding(.vertical, 2)
118        }
119    }
120
121    private func milestoneSection(_ mr: MRDetail) -> some View {
122        Section("Milestone") {
123            Menu {
124                Button("None") { Task { await model.setMilestone(nil) } }
125                ForEach(model.availableMilestones ?? []) { milestone in
126                    Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") {
127                        Task { await model.setMilestone(milestone.title) }
128                    }
129                }
130            } label: {
131                HStack {
132                    Label(mr.milestone ?? "None", systemImage: "flag")
133                        .font(.gbSans(.subheadline))
134                    Spacer()
135                    Image(systemName: "chevron.up.chevron.down")
136                        .font(.gbSans(.caption2))
137                        .foregroundStyle(.secondary)
138                }
139            }
140            .disabled(model.working)
141            .task { await model.loadMilestones() }
142            .accessibilityIdentifier("mr-milestone-menu")
143        }
144    }
145
146    private var diffSection: some View {
147        Section {
148            NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) {
149                HStack {
150                    Label("Diff", systemImage: "plus.forwardslash.minus")
151                    Spacer()
152                    if let diff = model.diff {
153                        Text("+\(diff.additions)")
154                            .foregroundStyle(Color.gbOK)
155                        Text("\(diff.deletions)")
156                            .foregroundStyle(Color.gbBad)
157                    }
158                }
159                .font(.gbSans(.subheadline))
160            }
161        }
162    }
163
164    private func commitsSection(_ commits: [MRDetail.MRCommit]) -> some View {
165        Section("Commits") {
166            ForEach(commits) { commit in
167                HStack(spacing: 8) {
168                    Text(commit.shortSHA)
169                        .font(.gbMono(.caption))
170                        .foregroundStyle(.secondary)
171                    Text(commit.subject)
172                        .font(.gbSans(.subheadline))
173                        .lineLimit(1)
174                }
175            }
176        }
177    }
178
179    private func checksSection(_ checks: [MRDetail.Check], combined: String?) -> some View {
180        Section("Checks" + (combined.map { "\($0)" } ?? "")) {
181            ForEach(checks, id: \.context) { check in
182                HStack {
183                    Image(systemName: checkIcon(check.state))
184                        .foregroundStyle(checkColor(check.state))
185                    Text(check.context)
186                        .font(.gbSans(.subheadline))
187                    Spacer()
188                    Text(check.state)
189                        .font(.gbSans(.caption))
190                        .foregroundStyle(.secondary)
191                }
192            }
193        }
194    }
195
196    private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View {
197        Section("Reviews") {
198            ForEach(reviews) { review in
199                HStack {
200                    Image(systemName: review.verdict == "approve"
201                        ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
202                        .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn)
203                    Text(review.reviewer)
204                        .font(.gbSans(.subheadline))
205                    Spacer()
206                    if review.stale {
207                        GBChip("stale", .gbWarn)
208                    }
209                }
210            }
211        }
212    }
213
214    private var threadsSection: some View {
215        Section("Threads — \(model.unresolvedCount) unresolved") {
216            ForEach(model.threads) { thread in
217                ReviewThreadView(thread: thread, model: model)
218            }
219        }
220    }
221
222    private func commentsSection(_ comments: [MRDetail.MRComment]) -> some View {
223        Section("Comments") {
224            ForEach(comments) { comment in
225                VStack(alignment: .leading, spacing: 4) {
226                    HStack {
227                        Text(comment.author)
228                            .font(.gbSans(.caption).weight(.semibold))
229                        Text(comment.createdAt, format: .relative(presentation: .named))
230                            .font(.gbSans(.caption))
231                            .foregroundStyle(.tertiary)
232                    }
233                    MarkdownView(markdown: comment.body)
234                        .font(.gbSans(.subheadline))
235                }
236                .padding(.vertical, 2)
237            }
238
239            HStack {
240                TextField("Comment", text: $commentText, axis: .vertical)
241                    .lineLimit(1...5)
242                Button {
243                    let text = commentText
244                    commentText = ""
245                    Task { await model.comment(text) }
246                } label: {
247                    Image(systemName: "arrow.up.circle.fill")
248                }
249                .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
250                    || model.working)
251            }
252        }
253    }
254
255    @ToolbarContentBuilder
256    private var toolbar: some ToolbarContent {
257        ToolbarItem(placement: .topBarTrailing) {
258            if let mr = model.state.value, mr.isOpen {
259                Menu {
260                    Button {
261                        draftTitle = mr.title
262                        draftBody = mr.body ?? ""
263                        editing = true
264                    } label: {
265                        Label("Edit", systemImage: "pencil")
266                    }
267                    Divider()
268                    Button {
269                        Task { await model.review(.approve) }
270                    } label: {
271                        Label("Approve", systemImage: "checkmark.circle")
272                    }
273                    Button {
274                        Task { await model.review(.requestChanges) }
275                    } label: {
276                        Label("Request Changes", systemImage: "exclamationmark.circle")
277                    }
278                    Divider()
279                    Button {
280                        confirmingMerge = true
281                    } label: {
282                        Label("Merge", systemImage: "arrow.triangle.merge")
283                    }
284                    Button(role: .destructive) {
285                        confirmingClose = true
286                    } label: {
287                        Label("Close", systemImage: "xmark.circle")
288                    }
289                } label: {
290                    if model.working {
291                        ProgressView()
292                    } else {
293                        Image(systemName: "ellipsis.circle")
294                    }
295                }
296                .disabled(model.working)
297                .accessibilityIdentifier("mr-actions-menu")
298            }
299        }
300    }
301
302    private func checkIcon(_ state: String) -> String {
303        switch state {
304        case "success": "checkmark.circle.fill"
305        case "failure", "error": "xmark.circle.fill"
306        case "pending", "running": "circle.dotted"
307        default: "questionmark.circle"
308        }
309    }
310
311    private func checkColor(_ state: String) -> Color {
312        switch state {
313        case "success": .gbOK
314        case "failure", "error": .gbBad
315        case "pending", "running": .gbWarn
316        default: .secondary
317        }
318    }
319}
320
321/// One review thread: anchor, comments, reply, resolve.
322struct ReviewThreadView: View {
323
324    let thread: ReviewThread
325    let model: MRDetailViewModel
326    @State private var replyText = ""
327
328    var body: some View {
329        VStack(alignment: .leading, spacing: 6) {
330            HStack(spacing: 6) {
331                Image(systemName: thread.isResolved
332                    ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
333                    .font(.gbSans(.caption))
334                    .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn)
335                Text("\(thread.path):\(thread.line)")
336                    .font(.gbMono(.caption))
337                    .lineLimit(1)
338                if thread.stale {
339                    GBChip("stale", .gbWarn)
340                }
341                Spacer()
342                Button(thread.isResolved ? "Reopen" : "Resolve") {
343                    Task { await model.setResolved(thread, !thread.isResolved) }
344                }
345                .font(.gbSans(.caption))
346                .buttonStyle(.bordered)
347                .disabled(model.working)
348            }
349            ForEach(thread.comments) { comment in
350                VStack(alignment: .leading, spacing: 2) {
351                    HStack {
352                        Text(comment.author)
353                            .font(.gbSans(.caption).weight(.semibold))
354                        Text(comment.createdAt, format: .relative(presentation: .named))
355                            .font(.gbSans(.caption2))
356                            .foregroundStyle(.tertiary)
357                    }
358                    Text(comment.body)
359                        .font(.gbSans(.subheadline))
360                }
361            }
362            if !thread.isResolved {
363                HStack {
364                    TextField("Reply", text: $replyText, axis: .vertical)
365                        .font(.gbSans(.subheadline))
366                        .lineLimit(1...4)
367                    Button {
368                        let text = replyText
369                        replyText = ""
370                        Task { await model.reply(to: thread, text) }
371                    } label: {
372                        Image(systemName: "arrow.up.circle.fill")
373                    }
374                    .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
375                        || model.working)
376                }
377            }
378        }
379        .padding(.vertical, 4)
380    }
381}