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}