gitbay/Views/MRs/MRView.swift
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}