| @@ -9,6 +9,9 @@ struct MRView: View { |
| 9 | 9 | @State private var editing = false |
| 10 | 10 | @State private var draftTitle = "" |
| 11 | 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 | 16 | init(client: GitbayClient, repo: String, number: Int64) { |
| 14 | 17 | _model = State(initialValue: MRDetailViewModel( |
| @@ -35,6 +38,8 @@ struct MRView: View { |
| 35 | 38 | } |
| 36 | 39 | |
| 37 | 40 | milestoneSection(mr) |
| 41 | stackSection(mr) |
| 42 | reviewersSection(mr) |
| 38 | 43 | diffSection |
| 39 | 44 | |
| 40 | 45 | if let commits = mr.commits, !commits.isEmpty { |
| @@ -59,18 +64,49 @@ struct MRView: View { |
| 59 | 64 | .task { await model.load() } |
| 60 | 65 | .refreshable { await model.load() } |
| 61 | 66 | .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 | } |
| 66 | 75 | Button("Cancel", role: .cancel) {} |
| 67 | 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 | 83 | .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) { |
| 71 | 84 | Button("Close", role: .destructive) { Task { await model.close() } } |
| 72 | 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 | 110 | .sheet(isPresented: $editing) { |
| 75 | 111 | ComposeSheet( |
| 76 | 112 | heading: "Edit !\(model.number)", |
| @@ -98,6 +134,9 @@ struct MRView: View { |
| 98 | 134 | .font(.gbSans(.headline)) |
| 99 | 135 | HStack(spacing: 6) { |
| 100 | 136 | MRStateBadge(state: mr.state) |
| 137 | if mr.draft { |
| 138 | GBChip("draft", .secondary) |
| 139 | } |
| 101 | 140 | Text(mr.source.isEmpty ? "(source gone)" : mr.source) |
| 102 | 141 | .lineLimit(1) |
| 103 | 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 | 285 | private var diffSection: some View { |
| 174 | 286 | Section { |
| 175 | 287 | NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) { |
| @@ -327,11 +439,25 @@ struct MRView: View { |
| 327 | 439 | Label("Request Changes", systemImage: "exclamationmark.circle") |
| 328 | 440 | } |
| 329 | 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 | 454 | Button { |
| 331 | 455 | confirmingMerge = true |
| 332 | 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 | 461 | Button(role: .destructive) { |
| 336 | 462 | confirmingClose = true |
| 337 | 463 | } label: { |
| @@ -430,3 +556,51 @@ struct ReviewThreadView: View { |
| 430 | 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 | } |