| @@ -9,6 +9,9 @@ struct MRView: View { |
| 9 | @State private var editing = false |
9 | @State private var editing = false |
| 10 | @State private var draftTitle = "" |
10 | @State private var draftTitle = "" |
| 11 | @State private var draftBody = "" |
11 | @State private var draftBody = "" |
| |
12 | @State private var editingReviewer = "" |
| |
13 | @State private var retargeting = false |
| |
14 | @State private var pendingRetarget: String? |
| 12 | |
15 | |
| 13 | init(client: GitbayClient, repo: String, number: Int64) { |
16 | init(client: GitbayClient, repo: String, number: Int64) { |
| 14 | _model = State(initialValue: MRDetailViewModel( |
17 | _model = State(initialValue: MRDetailViewModel( |
| @@ -35,6 +38,8 @@ struct MRView: View { |
| 35 | } |
38 | } |
| 36 | |
39 | |
| 37 | milestoneSection(mr) |
40 | milestoneSection(mr) |
| |
41 | stackSection(mr) |
| |
42 | reviewersSection(mr) |
| 38 | diffSection |
43 | diffSection |
| 39 | |
44 | |
| 40 | if let commits = mr.commits, !commits.isEmpty { |
45 | if let commits = mr.commits, !commits.isEmpty { |
| @@ -59,18 +64,49 @@ struct MRView: View { |
| 59 | .task { await model.load() } |
64 | .task { await model.load() } |
| 60 | .refreshable { await model.load() } |
65 | .refreshable { await model.load() } |
| 61 | .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) { |
66 | .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) { |
| 62 | Button("Merge") { Task { await model.merge() } } |
67 | if let mr = model.state.value, !mr.stacked.isEmpty { |
| 63 | Button("Squash") { Task { await model.merge(strategy: "squash") } } |
68 | Button("Fast-forward") { Task { await model.merge(strategy: "ff") } } |
| 64 | Button("Fast-forward") { Task { await model.merge(strategy: "ff") } } |
69 | } else { |
| 65 | Button("Rebase") { Task { await model.merge(strategy: "rebase") } } |
70 | Button("Merge") { Task { await model.merge() } } |
| |
71 | Button("Squash") { Task { await model.merge(strategy: "squash") } } |
| |
72 | Button("Fast-forward") { Task { await model.merge(strategy: "ff") } } |
| |
73 | Button("Rebase") { Task { await model.merge(strategy: "rebase") } } |
| |
74 | } |
| 66 | Button("Cancel", role: .cancel) {} |
75 | Button("Cancel", role: .cancel) {} |
| 67 | } message: { |
76 | } message: { |
| 68 | Text("The server enforces approvals, threads and checks — a refusal will say why.") |
77 | if let mr = model.state.value, !mr.stacked.isEmpty { |
| |
78 | Text("Merge requests are stacked on this one. Only a fast-forward merge is offered — squash and rebase would rewrite the commits the stack builds on.") |
| |
79 | } else { |
| |
80 | Text("The server enforces approvals, threads and checks — a refusal will say why.") |
| |
81 | } |
| 69 | } |
82 | } |
| 70 | .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) { |
83 | .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) { |
| 71 | Button("Close", role: .destructive) { Task { await model.close() } } |
84 | Button("Close", role: .destructive) { Task { await model.close() } } |
| 72 | Button("Cancel", role: .cancel) {} |
85 | Button("Cancel", role: .cancel) {} |
| 73 | } |
86 | } |
| |
87 | .confirmationDialog( |
| |
88 | "Retarget to \(pendingRetarget ?? "")?", |
| |
89 | isPresented: .init( |
| |
90 | get: { pendingRetarget != nil }, |
| |
91 | set: { if !$0 { pendingRetarget = nil } } |
| |
92 | ), |
| |
93 | titleVisibility: .visible |
| |
94 | ) { |
| |
95 | Button("Retarget") { |
| |
96 | guard let branch = pendingRetarget else { return } |
| |
97 | pendingRetarget = nil |
| |
98 | Task { await model.retarget(to: branch) } |
| |
99 | } |
| |
100 | Button("Cancel", role: .cancel) { pendingRetarget = nil } |
| |
101 | } message: { |
| |
102 | Text("Existing approvals were of the diff against the old target and will no longer apply.") |
| |
103 | } |
| |
104 | .sheet(isPresented: $retargeting) { |
| |
105 | RetargetSheet(model: model) { branch in |
| |
106 | retargeting = false |
| |
107 | pendingRetarget = branch |
| |
108 | } |
| |
109 | } |
| 74 | .sheet(isPresented: $editing) { |
110 | .sheet(isPresented: $editing) { |
| 75 | ComposeSheet( |
111 | ComposeSheet( |
| 76 | heading: "Edit !\(model.number)", |
112 | heading: "Edit !\(model.number)", |
| @@ -98,6 +134,9 @@ struct MRView: View { |
| 98 | .font(.gbSans(.headline)) |
134 | .font(.gbSans(.headline)) |
| 99 | HStack(spacing: 6) { |
135 | HStack(spacing: 6) { |
| 100 | MRStateBadge(state: mr.state) |
136 | MRStateBadge(state: mr.state) |
| |
137 | if mr.draft { |
| |
138 | GBChip("draft", .secondary) |
| |
139 | } |
| 101 | Text(mr.source.isEmpty ? "(source gone)" : mr.source) |
140 | Text(mr.source.isEmpty ? "(source gone)" : mr.source) |
| 102 | .lineLimit(1) |
141 | .lineLimit(1) |
| 103 | Image(systemName: "arrow.right") |
142 | Image(systemName: "arrow.right") |
| @@ -170,6 +209,79 @@ struct MRView: View { |
| 170 | } |
209 | } |
| 171 | } |
210 | } |
| 172 | |
211 | |
| |
212 | /// Shown only when this merge request is part of a stack. Each row |
| |
213 | /// spells out its own direction — "stacked on" vs. "is stacked on |
| |
214 | /// this" mean opposite things and look alike if abbreviated. |
| |
215 | @ViewBuilder |
| |
216 | private func stackSection(_ mr: MRDetail) -> some View { |
| |
217 | if mr.stackedOn != nil || !mr.stacked.isEmpty { |
| |
218 | Section("Stack") { |
| |
219 | if let base = mr.stackedOn { |
| |
220 | NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: base.number)) { |
| |
221 | Label("stacked on !\(base.number) — \(base.title)", systemImage: "arrow.up.to.line") |
| |
222 | .font(.gbSans(.subheadline)) |
| |
223 | .lineLimit(1) |
| |
224 | } |
| |
225 | } |
| |
226 | ForEach(mr.stacked) { ref in |
| |
227 | NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: ref.number)) { |
| |
228 | Label("!\(ref.number) is stacked on this — \(ref.title)", systemImage: "arrow.down.to.line") |
| |
229 | .font(.gbSans(.subheadline)) |
| |
230 | .lineLimit(1) |
| |
231 | } |
| |
232 | } |
| |
233 | } |
| |
234 | } |
| |
235 | } |
| |
236 | |
| |
237 | private func reviewersSection(_ mr: MRDetail) -> some View { |
| |
238 | Section("Reviewers") { |
| |
239 | reviewerFlow(mr.reviewRequests) |
| |
240 | HStack { |
| |
241 | TextField("Request review", text: $editingReviewer) |
| |
242 | .autocorrectionDisabled() |
| |
243 | .textInputAutocapitalization(.never) |
| |
244 | Button { |
| |
245 | let user = editingReviewer.trimmingCharacters(in: .whitespaces) |
| |
246 | editingReviewer = "" |
| |
247 | Task { await model.requestReview(from: user) } |
| |
248 | } label: { |
| |
249 | Image(systemName: "plus.circle.fill") |
| |
250 | } |
| |
251 | .disabled(editingReviewer.trimmingCharacters(in: .whitespaces).isEmpty || model.working) |
| |
252 | } |
| |
253 | } |
| |
254 | } |
| |
255 | |
| |
256 | /// The same add/remove chip shape `IssueView` uses for labels and |
| |
257 | /// assignees. |
| |
258 | @ViewBuilder |
| |
259 | private func reviewerFlow(_ items: [String]) -> some View { |
| |
260 | if !items.isEmpty { |
| |
261 | ScrollView(.horizontal, showsIndicators: false) { |
| |
262 | HStack(spacing: 6) { |
| |
263 | ForEach(items, id: \.self) { item in |
| |
264 | HStack(spacing: 3) { |
| |
265 | Text(item) |
| |
266 | Button { |
| |
267 | Task { await model.removeReviewRequest(item) } |
| |
268 | } label: { |
| |
269 | Image(systemName: "xmark.circle.fill") |
| |
270 | .foregroundStyle(.tertiary) |
| |
271 | } |
| |
272 | .disabled(model.working) |
| |
273 | } |
| |
274 | .font(.gbSans(.caption)) |
| |
275 | .padding(.horizontal, 8) |
| |
276 | .padding(.vertical, 3) |
| |
277 | .background(Color.secondary.opacity(0.07), in: gbChipShape) |
| |
278 | .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1)) |
| |
279 | } |
| |
280 | } |
| |
281 | } |
| |
282 | } |
| |
283 | } |
| |
284 | |
| 173 | private var diffSection: some View { |
285 | private var diffSection: some View { |
| 174 | Section { |
286 | Section { |
| 175 | NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) { |
287 | NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) { |
| @@ -327,11 +439,25 @@ struct MRView: View { |
| 327 | Label("Request Changes", systemImage: "exclamationmark.circle") |
439 | Label("Request Changes", systemImage: "exclamationmark.circle") |
| 328 | } |
440 | } |
| 329 | Divider() |
441 | Divider() |
| |
442 | Button { |
| |
443 | Task { await model.setDraft(!mr.draft) } |
| |
444 | } label: { |
| |
445 | Label(mr.draft ? "Mark as ready" : "Mark as draft", |
| |
446 | systemImage: mr.draft ? "checkmark.circle" : "pencil.circle") |
| |
447 | } |
| |
448 | Button { |
| |
449 | retargeting = true |
| |
450 | } label: { |
| |
451 | Label("Retarget", systemImage: "arrow.triangle.branch") |
| |
452 | } |
| |
453 | Divider() |
| 330 | Button { |
454 | Button { |
| 331 | confirmingMerge = true |
455 | confirmingMerge = true |
| 332 | } label: { |
456 | } label: { |
| 333 | Label("Merge", systemImage: "arrow.triangle.merge") |
457 | Label(mr.draft ? "Merge (mark as ready first)" : "Merge", |
| |
458 | systemImage: "arrow.triangle.merge") |
| 334 | } |
459 | } |
| |
460 | .disabled(mr.draft) |
| 335 | Button(role: .destructive) { |
461 | Button(role: .destructive) { |
| 336 | confirmingClose = true |
462 | confirmingClose = true |
| 337 | } label: { |
463 | } label: { |
| @@ -430,3 +556,51 @@ struct ReviewThreadView: View { |
| 430 | .padding(.vertical, 4) |
556 | .padding(.vertical, 4) |
| 431 | } |
557 | } |
| 432 | } |
558 | } |
| |
559 | |
| |
560 | /// A single-purpose branch picker for retarget. `model.branches` is |
| |
561 | /// `nil` while loading and `model.branchesError` distinguishes a failed |
| |
562 | /// `repo refs` fetch from a repository that genuinely has no other |
| |
563 | /// branches — an empty list must not read as a failed load. |
| |
564 | private struct RetargetSheet: View { |
| |
565 | |
| |
566 | let model: MRDetailViewModel |
| |
567 | let onPick: (String) -> Void |
| |
568 | |
| |
569 | @Environment(\.dismiss) private var dismiss |
| |
570 | |
| |
571 | var body: some View { |
| |
572 | NavigationStack { |
| |
573 | Group { |
| |
574 | if let error = model.branchesError { |
| |
575 | ContentUnavailableView { |
| |
576 | Label("Could not load branches", systemImage: "wifi.exclamationmark") |
| |
577 | } description: { |
| |
578 | Text(error) |
| |
579 | } |
| |
580 | } else if let branches = model.branches { |
| |
581 | if branches.isEmpty { |
| |
582 | ContentUnavailableView { |
| |
583 | Label("No other branches", systemImage: "arrow.triangle.branch") |
| |
584 | } description: { |
| |
585 | Text("This repository has no other branches to retarget to.") |
| |
586 | } |
| |
587 | } else { |
| |
588 | List(branches, id: \.self) { branch in |
| |
589 | Button(branch) { onPick(branch) } |
| |
590 | } |
| |
591 | } |
| |
592 | } else { |
| |
593 | ProgressView() |
| |
594 | } |
| |
595 | } |
| |
596 | .navigationTitle("Retarget !\(model.number)") |
| |
597 | .navigationBarTitleDisplayMode(.inline) |
| |
598 | .toolbar { |
| |
599 | ToolbarItem(placement: .cancellationAction) { |
| |
600 | Button("Cancel") { dismiss() } |
| |
601 | } |
| |
602 | } |
| |
603 | .task { await model.loadBranches() } |
| |
604 | } |
| |
605 | } |
| |
606 | } |