gitbay/Views/MRs/MRView.swift

5411753023a5e2958f17b523903abce380941107
gitbay-ios/gitbay/Views/MRs/MRView.swift history · blame · raw

365 lines · 13584 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                        Label(error, systemImage: "hand.raised")
 27                            .foregroundStyle(.orange)
 28                            .font(.subheadline)
 29                    }
 30                }
 31
 32                if let body = mr.body, !body.isEmpty {
 33                    Section {
 34                        MarkdownView(markdown: body)
 35                            .padding(.vertical, 4)
 36                    }
 37                }
 38
 39                diffSection
 40
 41                if let commits = mr.commits, !commits.isEmpty {
 42                    commitsSection(commits)
 43                }
 44                if let checks = mr.checks, !checks.isEmpty {
 45                    checksSection(checks, combined: mr.checksCombined)
 46                }
 47                if let reviews = mr.reviews, !reviews.isEmpty {
 48                    reviewsSection(reviews)
 49                }
 50                if !model.threads.isEmpty {
 51                    threadsSection
 52                }
 53                commentsSection(mr.comments ?? [])
 54            }
 55        }
 56        .overlay { LoadStateOverlay(state: model.state) }
 57        .navigationTitle("!\(model.number)")
 58        .navigationBarTitleDisplayMode(.inline)
 59        .toolbar { toolbar }
 60        .task { await model.load() }
 61        .refreshable { await model.load() }
 62        .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
 63            Button("Merge") { Task { await model.merge() } }
 64            Button("Squash") { Task { await model.merge(strategy: "squash") } }
 65            Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
 66            Button("Rebase") { Task { await model.merge(strategy: "rebase") } }
 67            Button("Cancel", role: .cancel) {}
 68        } message: {
 69            Text("The server enforces approvals, threads and checks — a refusal will say why.")
 70        }
 71        .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
 72            Button("Close", role: .destructive) { Task { await model.close() } }
 73            Button("Cancel", role: .cancel) {}
 74        }
 75        .sheet(isPresented: $editing) {
 76            ComposeSheet(
 77                heading: "Edit !\(model.number)",
 78                submitLabel: "Save",
 79                working: model.working,
 80                errorMessage: model.actionError,
 81                title: $draftTitle,
 82                bodyText: $draftBody
 83            ) {
 84                Task {
 85                    await model.edit(title: draftTitle, body: draftBody)
 86                    if model.actionError == nil { editing = false }
 87                }
 88            }
 89        }
 90    }
 91
 92    // MARK: - Sections
 93
 94    @ViewBuilder
 95    private func header(_ mr: MRDetail) -> some View {
 96        Section {
 97            VStack(alignment: .leading, spacing: 6) {
 98                Text(mr.title)
 99                    .font(.headline)
100                HStack(spacing: 6) {
101                    MRStateBadge(state: mr.state)
102                    Text(mr.source.isEmpty ? "(source gone)" : mr.source)
103                        .lineLimit(1)
104                    Image(systemName: "arrow.right")
105                        .font(.caption2)
106                    Text(mr.targetRef)
107                }
108                .font(.caption)
109                .foregroundStyle(.secondary)
110                HStack(spacing: 6) {
111                    Text("by \(mr.author)")
112                    Text(mr.createdAt, format: .relative(presentation: .named))
113                        .foregroundStyle(.tertiary)
114                }
115                .font(.caption)
116                .foregroundStyle(.secondary)
117            }
118            .padding(.vertical, 2)
119        }
120    }
121
122    private var diffSection: some View {
123        Section {
124            NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) {
125                HStack {
126                    Label("Diff", systemImage: "plus.forwardslash.minus")
127                    Spacer()
128                    if let diff = model.diff {
129                        Text("+\(diff.additions)")
130                            .foregroundStyle(.green)
131                        Text("−\(diff.deletions)")
132                            .foregroundStyle(.red)
133                    }
134                }
135                .font(.subheadline)
136            }
137        }
138    }
139
140    private func commitsSection(_ commits: [MRDetail.MRCommit]) -> some View {
141        Section("Commits") {
142            ForEach(commits) { commit in
143                HStack(spacing: 8) {
144                    Text(commit.shortSHA)
145                        .font(.caption.monospaced())
146                        .foregroundStyle(.secondary)
147                    Text(commit.subject)
148                        .font(.subheadline)
149                        .lineLimit(1)
150                }
151            }
152        }
153    }
154
155    private func checksSection(_ checks: [MRDetail.Check], combined: String?) -> some View {
156        Section("Checks" + (combined.map { " — \($0)" } ?? "")) {
157            ForEach(checks, id: \.context) { check in
158                HStack {
159                    Image(systemName: checkIcon(check.state))
160                        .foregroundStyle(checkColor(check.state))
161                    Text(check.context)
162                        .font(.subheadline)
163                    Spacer()
164                    Text(check.state)
165                        .font(.caption)
166                        .foregroundStyle(.secondary)
167                }
168            }
169        }
170    }
171
172    private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View {
173        Section("Reviews") {
174            ForEach(reviews) { review in
175                HStack {
176                    Image(systemName: review.verdict == "approve"
177                        ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
178                        .foregroundStyle(review.verdict == "approve" ? .green : .orange)
179                    Text(review.reviewer)
180                        .font(.subheadline)
181                    Spacer()
182                    if review.stale {
183                        Text("stale")
184                            .font(.caption2)
185                            .padding(.horizontal, 5)
186                            .padding(.vertical, 1)
187                            .background(.quaternary, in: Capsule())
188                    }
189                }
190            }
191        }
192    }
193
194    private var threadsSection: some View {
195        Section("Threads — \(model.unresolvedCount) unresolved") {
196            ForEach(model.threads) { thread in
197                ThreadView(thread: thread, model: model)
198            }
199        }
200    }
201
202    private func commentsSection(_ comments: [MRDetail.MRComment]) -> some View {
203        Section("Comments") {
204            ForEach(comments) { comment in
205                VStack(alignment: .leading, spacing: 4) {
206                    HStack {
207                        Text(comment.author)
208                            .font(.caption.weight(.semibold))
209                        Text(comment.createdAt, format: .relative(presentation: .named))
210                            .font(.caption)
211                            .foregroundStyle(.tertiary)
212                    }
213                    MarkdownView(markdown: comment.body)
214                        .font(.subheadline)
215                }
216                .padding(.vertical, 2)
217            }
218
219            HStack {
220                TextField("Comment", text: $commentText, axis: .vertical)
221                    .lineLimit(1...5)
222                Button {
223                    let text = commentText
224                    commentText = ""
225                    Task { await model.comment(text) }
226                } label: {
227                    Image(systemName: "arrow.up.circle.fill")
228                }
229                .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
230                    || model.working)
231            }
232        }
233    }
234
235    @ToolbarContentBuilder
236    private var toolbar: some ToolbarContent {
237        ToolbarItem(placement: .topBarTrailing) {
238            if let mr = model.state.value, mr.isOpen {
239                Menu {
240                    Button {
241                        draftTitle = mr.title
242                        draftBody = mr.body ?? ""
243                        editing = true
244                    } label: {
245                        Label("Edit", systemImage: "pencil")
246                    }
247                    Divider()
248                    Button {
249                        Task { await model.review(.approve) }
250                    } label: {
251                        Label("Approve", systemImage: "checkmark.circle")
252                    }
253                    Button {
254                        Task { await model.review(.requestChanges) }
255                    } label: {
256                        Label("Request Changes", systemImage: "exclamationmark.circle")
257                    }
258                    Divider()
259                    Button {
260                        confirmingMerge = true
261                    } label: {
262                        Label("Merge", systemImage: "arrow.triangle.merge")
263                    }
264                    Button(role: .destructive) {
265                        confirmingClose = true
266                    } label: {
267                        Label("Close", systemImage: "xmark.circle")
268                    }
269                } label: {
270                    if model.working {
271                        ProgressView()
272                    } else {
273                        Image(systemName: "ellipsis.circle")
274                    }
275                }
276                .disabled(model.working)
277                .accessibilityIdentifier("mr-actions-menu")
278            }
279        }
280    }
281
282    private func checkIcon(_ state: String) -> String {
283        switch state {
284        case "success": "checkmark.circle.fill"
285        case "failure", "error": "xmark.circle.fill"
286        case "pending", "running": "circle.dotted"
287        default: "questionmark.circle"
288        }
289    }
290
291    private func checkColor(_ state: String) -> Color {
292        switch state {
293        case "success": .green
294        case "failure", "error": .red
295        case "pending", "running": .orange
296        default: .secondary
297        }
298    }
299}
300
301/// One review thread: anchor, comments, reply, resolve.
302private struct ThreadView: View {
303
304    let thread: ReviewThread
305    let model: MRDetailViewModel
306    @State private var replyText = ""
307
308    var body: some View {
309        VStack(alignment: .leading, spacing: 6) {
310            HStack(spacing: 6) {
311                Image(systemName: thread.isResolved
312                    ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
313                    .font(.caption)
314                    .foregroundStyle(thread.isResolved ? .green : .orange)
315                Text("\(thread.path):\(thread.line)")
316                    .font(.caption.monospaced())
317                    .lineLimit(1)
318                if thread.stale {
319                    Text("stale")
320                        .font(.caption2)
321                        .padding(.horizontal, 5)
322                        .padding(.vertical, 1)
323                        .background(.quaternary, in: Capsule())
324                }
325                Spacer()
326                Button(thread.isResolved ? "Reopen" : "Resolve") {
327                    Task { await model.setResolved(thread, !thread.isResolved) }
328                }
329                .font(.caption)
330                .buttonStyle(.bordered)
331                .disabled(model.working)
332            }
333            ForEach(thread.comments) { comment in
334                VStack(alignment: .leading, spacing: 2) {
335                    HStack {
336                        Text(comment.author)
337                            .font(.caption.weight(.semibold))
338                        Text(comment.createdAt, format: .relative(presentation: .named))
339                            .font(.caption2)
340                            .foregroundStyle(.tertiary)
341                    }
342                    Text(comment.body)
343                        .font(.subheadline)
344                }
345            }
346            if !thread.isResolved {
347                HStack {
348                    TextField("Reply", text: $replyText, axis: .vertical)
349                        .font(.subheadline)
350                        .lineLimit(1...4)
351                    Button {
352                        let text = replyText
353                        replyText = ""
354                        Task { await model.reply(to: thread, text) }
355                    } label: {
356                        Image(systemName: "arrow.up.circle.fill")
357                    }
358                    .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
359                        || model.working)
360                }
361            }
362        }
363        .padding(.vertical, 4)
364    }
365}