Commit c694ac101b

c694ac101b2231288e9295f4adebf55ea9d9df30

parent: a61992cc96

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 20:44 UTC

Draft, stack and reviewers on the merge request screen

Layout: unified · split

gitbay/MRs/MRDetailViewModel.swift +22 −5
@@ -124,12 +124,29 @@ final class MRDetailViewModel {
124124 await perform(["mr", "review", "request"] + ref + ["--remove", user])
125125 }
126126
127 /// Branches for the retarget picker; fetched on demand from `repo refs`.
128 private(set) var branches: [String] = []
129
127 /// Branches for the retarget picker, fetched on demand from `repo refs`.
128 /// `nil` means "not yet loaded" so the picker can tell that apart from
129 /// a repository that genuinely has no other branches; a failed fetch
130 /// leaves `branches` nil and sets `branchesError` instead of silently
131 /// showing an empty list.
132 private(set) var branches: [String]?
133 private(set) var branchesError: String?
134
135 /// Cached for the life of this view model, same as `loadMilestones()`:
136 /// branches can go stale after a push, but re-opening the retarget
137 /// picker should not re-fetch every time. A pull-to-refresh of the
138 /// whole screen doesn't clear this cache either — retarget is a
139 /// secondary action, not the primary content `load()` refreshes.
130140 func loadBranches() async {
131 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
132 branches = refs?.branches.map(\.name) ?? []
141 guard branches == nil else { return }
142 do {
143 let refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
144 branches = refs.branches.map(\.name)
145 } catch let error as GitbayError {
146 branchesError = error.userFacingMessage
147 } catch {
148 branchesError = GitbayError.transport(error).userFacingMessage
149 }
133150 }
134151
135152 private func perform(_ argv: [String], stdin: String? = nil) async {
gitbay/Views/MRs/MRListView.swift +8
@@ -177,6 +177,9 @@ struct MRRow: View {
177177 }
178178 HStack(spacing: 6) {
179179 MRStateBadge(state: mr.state)
180 if mr.draft {
181 GBChip("draft", .secondary)
182 }
180183 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
181184 .lineLimit(1)
182185 Image(systemName: "arrow.right")
@@ -188,6 +191,11 @@ struct MRRow: View {
188191 }
189192 .font(.gbSans(.caption))
190193 .foregroundStyle(.secondary)
194 if let stackedOn = mr.stackedOn {
195 Text("stacked on !\(stackedOn.number)")
196 .font(.gbSans(.caption2))
197 .foregroundStyle(.tertiary)
198 }
191199 }
192200 .padding(.vertical, 2)
193201 }
gitbay/Views/MRs/MRView.swift +180 −6
@@ -9,6 +9,9 @@ struct MRView: View {
99 @State private var editing = false
1010 @State private var draftTitle = ""
1111 @State private var draftBody = ""
12 @State private var editingReviewer = ""
13 @State private var retargeting = false
14 @State private var pendingRetarget: String?
1215
1316 init(client: GitbayClient, repo: String, number: Int64) {
1417 _model = State(initialValue: MRDetailViewModel(
@@ -35,6 +38,8 @@ struct MRView: View {
3538 }
3639
3740 milestoneSection(mr)
41 stackSection(mr)
42 reviewersSection(mr)
3843 diffSection
3944
4045 if let commits = mr.commits, !commits.isEmpty {
@@ -59,18 +64,49 @@ struct MRView: View {
5964 .task { await model.load() }
6065 .refreshable { await model.load() }
6166 .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") } }
67 if let mr = model.state.value, !mr.stacked.isEmpty {
68 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
69 } else {
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 }
6675 Button("Cancel", role: .cancel) {}
6776 } 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 }
6982 }
7083 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
7184 Button("Close", role: .destructive) { Task { await model.close() } }
7285 Button("Cancel", role: .cancel) {}
7386 }
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 }
74110 .sheet(isPresented: $editing) {
75111 ComposeSheet(
76112 heading: "Edit !\(model.number)",
@@ -98,6 +134,9 @@ struct MRView: View {
98134 .font(.gbSans(.headline))
99135 HStack(spacing: 6) {
100136 MRStateBadge(state: mr.state)
137 if mr.draft {
138 GBChip("draft", .secondary)
139 }
101140 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
102141 .lineLimit(1)
103142 Image(systemName: "arrow.right")
@@ -170,6 +209,79 @@ struct MRView: View {
170209 }
171210 }
172211
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
173285 private var diffSection: some View {
174286 Section {
175287 NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) {
@@ -327,11 +439,25 @@ struct MRView: View {
327439 Label("Request Changes", systemImage: "exclamationmark.circle")
328440 }
329441 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()
330454 Button {
331455 confirmingMerge = true
332456 } label: {
333 Label("Merge", systemImage: "arrow.triangle.merge")
457 Label(mr.draft ? "Merge (mark as ready first)" : "Merge",
458 systemImage: "arrow.triangle.merge")
334459 }
460 .disabled(mr.draft)
335461 Button(role: .destructive) {
336462 confirmingClose = true
337463 } label: {
@@ -430,3 +556,51 @@ struct ReviewThreadView: View {
430556 .padding(.vertical, 4)
431557 }
432558}
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.
564private 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}