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 {
124 await perform(["mr", "review", "request"] + ref + ["--remove", user]) 124 await perform(["mr", "review", "request"] + ref + ["--remove", user])
125 } 125 }
126 126
127 /// Branches for the retarget picker; fetched on demand from `repo refs`. 127 /// Branches for the retarget picker, fetched on demand from `repo refs`.
128 private(set) var branches: [String] = [] 128 /// `nil` means "not yet loaded" so the picker can tell that apart from
129 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.
130 func loadBranches() async { 140 func loadBranches() async {
131 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self) 141 guard branches == nil else { return }
132 branches = refs?.branches.map(\.name) ?? [] 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 }
133 } 150 }
134 151
135 private func perform(_ argv: [String], stdin: String? = nil) async { 152 private func perform(_ argv: [String], stdin: String? = nil) async {
gitbay/Views/MRs/MRListView.swift +8
@@ -177,6 +177,9 @@ struct MRRow: View {
177 } 177 }
178 HStack(spacing: 6) { 178 HStack(spacing: 6) {
179 MRStateBadge(state: mr.state) 179 MRStateBadge(state: mr.state)
180 if mr.draft {
181 GBChip("draft", .secondary)
182 }
180 Text(mr.source.isEmpty ? "(source gone)" : mr.source) 183 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
181 .lineLimit(1) 184 .lineLimit(1)
182 Image(systemName: "arrow.right") 185 Image(systemName: "arrow.right")
@@ -188,6 +191,11 @@ struct MRRow: View {
188 } 191 }
189 .font(.gbSans(.caption)) 192 .font(.gbSans(.caption))
190 .foregroundStyle(.secondary) 193 .foregroundStyle(.secondary)
194 if let stackedOn = mr.stackedOn {
195 Text("stacked on !\(stackedOn.number)")
196 .font(.gbSans(.caption2))
197 .foregroundStyle(.tertiary)
198 }
191 } 199 }
192 .padding(.vertical, 2) 200 .padding(.vertical, 2)
193 } 201 }
gitbay/Views/MRs/MRView.swift +180 −6
@@ -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.
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}