import SwiftUI struct MRView: View { @State private var model: MRDetailViewModel @State private var commentText = "" @State private var commentFormat = "md" @State private var confirmingMerge = false @State private var confirmingQueue = false @State private var confirmingClose = false @State private var confirmingPrune = false @State private var editing = false @State private var draftTitle = "" @State private var draftBody = "" @State private var draftFormat = "md" @State private var editingReviewer = "" @State private var editingLabel = "" @State private var retargeting = false @State private var pendingRetarget: String? @State private var superseding = false @State private var pendingSuccessor: Int64? init(client: GitbayClient, repo: String, number: Int64) { _model = State(initialValue: MRDetailViewModel( client: client, repoPath: repo, number: number )) } var body: some View { List { if let mr = model.state.value { header(mr) if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } Section { if let body = mr.body, !body.isEmpty { FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath) .padding(.vertical, 4) } ReactionBar(reactions: mr.reactions ?? [], disabled: model.working, idPrefix: "mr-reaction") { name in Task { await model.react(name, comment: nil) } } } if let gates = mr.gates, mr.isOpen { gatesSection(gates) } if let checks = mr.checks, !checks.isEmpty { checksSection(checks, combined: mr.checksCombined) } milestoneSection(mr) labelsSection(mr) stackSection(mr) supersededSection(mr) reviewersSection(mr) diffSection if !model.revisions.isEmpty { revisionsSection } if let commits = mr.commits, !commits.isEmpty { commitsSection(commits) } if let reviews = mr.reviews, !reviews.isEmpty { reviewsSection(reviews) } if !model.threads.isEmpty { threadsSection } commentsSection(mr.comments ?? []) } } .overlay { LoadStateOverlay(state: model.state) } .referenceNavigation() .scrollDismissesKeyboard(.interactively) .navigationTitle("!\(model.number)") .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .task { await model.load() } .task(id: model.state.value?.state) { await model.checkCanPruneHead() } .refreshable { await model.load() } .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) { strategyButtons(whenReady: false) } message: { if let mr = model.state.value, !mr.stacked.isEmpty { Text("Merge requests are stacked on this one. Squash and rebase are unavailable — they would rewrite the commits the stack builds on.") } else { Text("The server enforces approvals, threads and checks — a refusal will say why.") } } .confirmationDialog("Merge !\(model.number) when ready?", isPresented: $confirmingQueue) { strategyButtons(whenReady: true) } message: { Text("The server merges it once every gate passes, and at once if they already do.") } .confirmationDialog( "Prune the head of !\(model.number)? Its head ref and the commits only it kept are deleted for good, and its diff can no longer be shown. The audit log records it.", isPresented: $confirmingPrune, titleVisibility: .visible ) { Button("Prune Head", role: .destructive) { Task { await model.pruneHead() } } Button("Cancel", role: .cancel) {} } .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) { Button("Close", role: .destructive) { Task { await model.close() } } Button("Close in favour of another…") { superseding = true } Button("Cancel", role: .cancel) {} } .confirmationDialog( "Close !\(model.number) in favour of !\(pendingSuccessor ?? 0)?", isPresented: .init( get: { pendingSuccessor != nil }, set: { if !$0 { pendingSuccessor = nil } } ), titleVisibility: .visible ) { Button("Close", role: .destructive) { guard let successor = pendingSuccessor else { return } pendingSuccessor = nil Task { await model.close(inFavourOf: successor) } } Button("Cancel", role: .cancel) { pendingSuccessor = nil } } message: { Text("The page will name the merge request that replaces this one, and that one will list this as superseded.") } .sheet(isPresented: $superseding) { SupersedeSheet(model: model) { successor in superseding = false pendingSuccessor = successor } } .confirmationDialog( "Retarget to \(pendingRetarget ?? "")?", isPresented: .init( get: { pendingRetarget != nil }, set: { if !$0 { pendingRetarget = nil } } ), titleVisibility: .visible ) { Button("Retarget") { guard let branch = pendingRetarget else { return } pendingRetarget = nil Task { await model.retarget(to: branch) } } Button("Cancel", role: .cancel) { pendingRetarget = nil } } message: { Text("Existing approvals were of the diff against the old target and will no longer apply.") } .sheet(isPresented: $retargeting) { RetargetSheet(model: model) { branch in retargeting = false pendingRetarget = branch } } .sheet(isPresented: $editing) { ComposeSheet( heading: "Edit !\(model.number)", submitLabel: "Save", working: model.working, errorMessage: model.actionError, title: $draftTitle, bodyText: $draftBody, format: $draftFormat ) { Task { await model.edit(title: draftTitle, body: draftBody, format: draftFormat) if model.actionError == nil { editing = false } } } } } // MARK: - Sections @ViewBuilder private func header(_ mr: MRDetail) -> some View { Section { VStack(alignment: .leading, spacing: 6) { Text(mr.title) .font(.gbSans(.headline)) HStack(spacing: 6) { MRStateBadge(state: mr.state) if mr.draft { GBChip("draft", .secondary) } Text(mr.source.isEmpty ? "(source gone)" : mr.source) .lineLimit(1) Image(systemName: "arrow.right") .font(.gbSans(.caption2)) Text(mr.targetRef) } .font(.gbSans(.caption)) .foregroundStyle(.secondary) HStack(spacing: 6) { Text("by \(mr.author)") Text(mr.createdAt, format: .relative(presentation: .named)) .foregroundStyle(.tertiary) } .font(.gbSans(.caption)) .foregroundStyle(.secondary) resolution(mr) } .padding(.vertical, 2) } } /// What became of the merge request, once it is no longer open. The /// actor is absent for imported rows, which carry only a time. @ViewBuilder private func resolution(_ mr: MRDetail) -> some View { if let at = mr.mergedAt { stampLine("merged", by: mr.mergedBy, at: at) } else if let at = mr.closedAt { stampLine(mr.supersededBy.map { "closed in favour of !\($0)" } ?? "closed without merging", by: mr.closedBy, at: at) } } private func stampLine(_ verb: String, by actor: String?, at: Date) -> some View { HStack(spacing: 6) { Text(actor.map { "\(verb) by \($0)" } ?? verb) Text(at, format: Self.stamp) .foregroundStyle(.tertiary) } .font(.gbSans(.caption)) .foregroundStyle(.secondary) } /// Reviews and checks are read for when they happened, so they carry /// the date rather than "3 days ago". private static let stamp = Date.FormatStyle.dateTime .year().month(.abbreviated).day().hour().minute() private func milestoneSection(_ mr: MRDetail) -> some View { Section("Milestone") { Menu { Button("None") { Task { await model.setMilestone(nil) } } ForEach(model.availableMilestones ?? []) { milestone in Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") { Task { await model.setMilestone(milestone.title) } } } } label: { HStack { Label(mr.milestone ?? "None", systemImage: "flag") .font(.gbSans(.subheadline)) Spacer() Image(systemName: "chevron.up.chevron.down") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } .disabled(model.working) .task { await model.loadMilestones() } .accessibilityIdentifier("mr-milestone-menu") } } private func labelsSection(_ mr: MRDetail) -> some View { Section("Labels") { RemovableChips(items: mr.labels, idPrefix: "mr-label", disabled: model.working, color: { model.colors.color($0) }, remove: { label in Task { await model.removeLabel(label) } }) HStack { TextField("Add label", text: $editingLabel) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("mr-label-field") Button { let label = editingLabel.trimmingCharacters(in: .whitespaces) editingLabel = "" Task { await model.addLabel(label) } } label: { Image(systemName: "plus.circle.fill") } .accessibilityLabel("Add label") .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working) .accessibilityIdentifier("mr-label-add") } } .task { await model.loadLabels() } } /// Shown only when this merge request is part of a stack. Each row /// spells out its own direction — "stacked on" vs. "is stacked on /// this" mean opposite things and look alike if abbreviated. /// The merge request that replaced this one, as a link; the stamp /// line names it, this row reaches it. @ViewBuilder private func supersededSection(_ mr: MRDetail) -> some View { if let successor = mr.supersededBy { Section { NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: successor)) { Label("Superseded by !\(successor)", systemImage: "arrow.right.to.line") .font(.gbSans(.subheadline)) } } } } @ViewBuilder private func stackSection(_ mr: MRDetail) -> some View { if mr.stackedOn != nil || !mr.stacked.isEmpty { Section("Stack") { if let base = mr.stackedOn { NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: base.number)) { Label("stacked on !\(base.number) — \(base.title)", systemImage: "arrow.up.to.line") .font(.gbSans(.subheadline)) .lineLimit(1) } } ForEach(mr.stacked) { ref in NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: ref.number)) { Label("!\(ref.number) is stacked on this — \(ref.title)", systemImage: "arrow.down.to.line") .font(.gbSans(.subheadline)) .lineLimit(1) } } } } } /// Every head the merge request has had. A revision after the first /// links to the range-diff from the one before it. private var revisionsSection: some View { Section("Revisions") { ForEach(Array(model.revisions.enumerated()), id: \.element.id) { index, revision in let row = HStack { Text("v\(revision.n)") .font(.gbSans(.subheadline).weight(.medium)) Text(String(revision.sha.prefix(10))) .font(.gbMono(.caption)) .foregroundStyle(.secondary) Spacer() if revision.isCurrent { GBChip("current", .secondary) } Text(revision.createdAt.prefix(10)) .font(.gbSans(.caption)) .foregroundStyle(.tertiary) } if index > 0 { NavigationLink(value: MRRoute.rangeDiff( repo: model.repoPath, number: model.number, from: model.revisions[index - 1].sha, to: revision.sha) ) { row } } else { row } } } } private func reviewersSection(_ mr: MRDetail) -> some View { Section("Reviewers") { RemovableChips(items: mr.reviewRequests, idPrefix: "mr-reviewer", disabled: model.working, remove: { user in Task { await model.removeReviewRequest(user) } }) HStack { TextField("Request review", text: $editingReviewer) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button { let user = editingReviewer.trimmingCharacters(in: .whitespaces) editingReviewer = "" Task { await model.requestReview(from: user) } } label: { Image(systemName: "plus.circle.fill") } .accessibilityLabel("Request review") .disabled(editingReviewer.trimmingCharacters(in: .whitespaces).isEmpty || model.working) } } } private var diffSection: some View { Section { NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) { HStack { Label("Diff", systemImage: "plus.forwardslash.minus") Spacer() if let diff = model.diff { Text("+\(diff.additions)") .foregroundStyle(Color.gbOK) Text("−\(diff.deletions)") .foregroundStyle(Color.gbBad) } } .font(.gbSans(.subheadline)) } } } private func commitsSection(_ commits: [MRDetail.MRCommit]) -> some View { Section("Commits") { ForEach(commits) { commit in HStack(spacing: 8) { Text(commit.shortSHA) .font(.gbMono(.caption)) .foregroundStyle(.secondary) Text(commit.subject) .font(.gbSans(.subheadline)) .lineLimit(1) } } } } private func checksSection(_ checks: [MRDetail.Check], combined: String?) -> some View { Section("Checks" + (combined.map { " — \($0)" } ?? "")) { ForEach(checks, id: \.context) { check in VStack(alignment: .leading, spacing: 2) { HStack { Image(systemName: checkIcon(check.state)) .foregroundStyle(checkColor(check.state)) Text(check.context) .font(.gbSans(.subheadline)) Spacer() Text(check.state) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } timing(check.updatedAt, duration: check.duration) } } } } /// The strategies the dialog offers. A stack rules out the two that /// rewrite the commits it builds on. @ViewBuilder private func strategyButtons(whenReady: Bool) -> some View { let stacked = !(model.state.value?.stacked.isEmpty ?? true) Button("Merge") { Task { await model.merge(whenReady: whenReady) } } if !stacked { Button("Squash") { Task { await model.merge(strategy: "squash", whenReady: whenReady) } } } Button("Fast-forward") { Task { await model.merge(strategy: "ff", whenReady: whenReady) } } if !stacked { Button("Rebase") { Task { await model.merge(strategy: "rebase", whenReady: whenReady) } } } Button("Cancel", role: .cancel) {} } /// The merge gates as the server will judge them, each row met or /// not, so a refusal is never the first time a rule is seen. private func gatesSection(_ gates: MRDetail.Gates) -> some View { Section { if let queued = model.state.value?.queued { queuedRow(queued) } if gates.draft { gateRow(false, "Draft — not asking to merge") } if gates.approvalsRequired > 0 { gateRow(gates.approvals.count >= gates.approvalsRequired, "\(gates.approvals.count) of \(gates.approvalsRequired) approval\(gates.approvalsRequired == 1 ? "" : "s")" + (gates.approvals.isEmpty ? "" : " — " + gates.approvals.joined(separator: ", "))) } if !gates.changesRequested.isEmpty { gateRow(false, "Changes requested by " + gates.changesRequested.joined(separator: ", ")) } if gates.codeownersRequired { if gates.ownersOutstanding.isEmpty { gateRow(true, "Code owners approved") } else { ForEach(gates.ownersOutstanding) { owners in gateRow(false, owners.files.joined(separator: ", ") + " — " + owners.owners.joined(separator: ", ")) } } } if gates.resolvedRequired { gateRow(gates.openThreads == 0, gates.openThreads == 0 ? "Threads resolved" : "\(gates.openThreads) thread\(gates.openThreads == 1 ? "" : "s") open") } if gates.checksRequired { gateRow(gates.checks == "success", "Checks " + (gates.checks ?? "not reported")) } gateRow(gates.fastForward, gates.fastForward ? "Fast-forward possible" : "Behind the target — rebase first") } header: { Text("Gates") } footer: { Text(gates.isMet ? "Ready to merge." : "Unmet: " + gates.unmet.joined(separator: ", ")) } } private func queuedRow(_ queued: MRDetail.Queued) -> some View { VStack(alignment: .leading, spacing: 2) { Label("Merges when ready" + (queued.strategy.map { " (\($0))" } ?? ""), systemImage: "clock.arrow.circlepath") .font(.gbSans(.subheadline).weight(.medium)) Text("Queued by \(queued.by) \(queued.queuedAt.formatted(.relative(presentation: .named)))" + (queued.reason.map { " — \($0)" } ?? "")) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } .accessibilityElement(children: .combine) .accessibilityIdentifier("mr-queued") } private func gateRow(_ met: Bool, _ text: String) -> some View { HStack(alignment: .firstTextBaseline) { Image(systemName: met ? "checkmark.circle.fill" : "circle") .foregroundStyle(met ? Color.gbOK : Color.gbWarn) Text(text) .font(.gbSans(.subheadline)) } } private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View { Section("Reviews") { ForEach(reviews) { review in VStack(alignment: .leading, spacing: 2) { HStack { Image(systemName: review.verdict == "approve" ? "checkmark.circle.fill" : "exclamationmark.circle.fill") .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn) Text(review.reviewer) .font(.gbSans(.subheadline)) Spacer() if review.stale { GBChip("stale", .gbWarn) } if !review.counts { GBChip("advisory", .secondary) } } timing(review.createdAt) } } } } /// When a review or check last said something, and for a CI check how /// long its build ran. Nothing renders against a server that does not /// report the time. @ViewBuilder private func timing(_ at: Date?, duration: String? = nil) -> some View { if let at { HStack(spacing: 6) { Text(at, format: Self.stamp) if let duration { Text("·") Text(duration) } } .font(.gbSans(.caption)) .foregroundStyle(.tertiary) } } private var threadsSection: some View { Section("Threads — \(model.unresolvedCount) unresolved") { ForEach(model.threads) { thread in ReviewThreadView(thread: thread, model: model) } } } private func commentsSection(_ comments: [MRDetail.MRComment]) -> some View { Section("Comments") { ForEach(comments) { comment in VStack(alignment: .leading, spacing: 4) { HStack { Text(comment.author) .font(.gbSans(.caption).weight(.semibold)) Text(comment.createdAt, format: .relative(presentation: .named)) .font(.gbSans(.caption)) .foregroundStyle(.tertiary) } FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) .font(.gbSans(.subheadline)) if let id = comment.commentID, !comment.isSystem { ReactionBar(reactions: comment.reactions ?? [], disabled: model.working, idPrefix: "mr-comment-\(id)-reaction") { name in Task { await model.react(name, comment: id) } } } } .padding(.vertical, 2) } HStack { TextField("Comment", text: $commentText, axis: .vertical) .lineLimit(1...5) .accessibilityIdentifier("mr-comment-field") Picker("Format", selection: $commentFormat) { Text("Markdown").tag("md") Text("Org").tag("org") } .pickerStyle(.segmented) .labelsHidden() .fixedSize() .accessibilityIdentifier("mr-comment-format") Button { let text = commentText commentText = "" Task { await model.comment(text, format: commentFormat) } } label: { Image(systemName: "arrow.up.circle.fill") .font(.title) .frame(minWidth: 44, minHeight: 44) .contentShape(Rectangle()) } .accessibilityLabel("Send comment") .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.working) } if !commentText.isEmpty { MarkupPreview(text: commentText, format: commentFormat, identifier: "mr-comment-preview") .font(.gbSans(.subheadline)) } } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { if let mr = model.state.value, mr.isOpen { Menu { Button { draftTitle = mr.title draftBody = mr.body ?? "" draftFormat = mr.bodyFormat ?? "md" editing = true } label: { Label("Edit", systemImage: "pencil") } Divider() Button { Task { await model.review(.approve) } } label: { Label("Approve", systemImage: "checkmark.circle") } Button { Task { await model.review(.requestChanges) } } label: { Label("Request Changes", systemImage: "exclamationmark.circle") } Divider() Button { Task { await model.setDraft(!mr.draft) } } label: { Label(mr.draft ? "Mark as ready" : "Mark as draft", systemImage: mr.draft ? "checkmark.circle" : "pencil.circle") } Button { retargeting = true } label: { Label("Retarget", systemImage: "arrow.triangle.branch") } Divider() Button { confirmingMerge = true } label: { Label(mr.draft ? "Merge (mark as ready first)" : "Merge", systemImage: "arrow.triangle.merge") } .disabled(mr.draft) if mr.queued != nil { Button { Task { await model.cancelQueuedMerge() } } label: { Label("Cancel queued merge", systemImage: "clock.badge.xmark") } } else { Button { confirmingQueue = true } label: { Label("Merge when ready", systemImage: "clock.arrow.circlepath") } } Button(role: .destructive) { confirmingClose = true } label: { Label("Close", systemImage: "xmark.circle") } } label: { if model.working { ProgressView() } else { Image(systemName: "ellipsis.circle") } } .accessibilityLabel("Actions") .disabled(model.working) .accessibilityIdentifier("mr-actions-menu") } else if model.canPruneHead { Menu { Button(role: .destructive) { confirmingPrune = true } label: { Label("Prune Head…", systemImage: "scissors") } } label: { if model.working { ProgressView() } else { Image(systemName: "ellipsis.circle") } } .accessibilityLabel("Actions") .disabled(model.working) .accessibilityIdentifier("mr-admin-menu") } } } private func checkIcon(_ state: String) -> String { switch state { case "success": "checkmark.circle.fill" case "failure", "error": "xmark.circle.fill" case "pending", "running": "circle.dotted" default: "questionmark.circle" } } private func checkColor(_ state: String) -> Color { switch state { case "success": .gbOK case "failure", "error": .gbBad case "pending", "running": .gbWarn default: .secondary } } } /// One review thread: anchor, comments, reply, resolve. struct ReviewThreadView: View { let thread: ReviewThread let model: MRDetailViewModel @State private var replyText = "" var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Image(systemName: thread.isResolved ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right") .font(.gbSans(.caption)) .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn) Text(thread.anchor) .font(.gbMono(.caption)) .lineLimit(1) if thread.stale { GBChip("stale", .gbWarn) } Spacer() Button(thread.isResolved ? "Reopen" : "Resolve") { Task { await model.setResolved(thread, !thread.isResolved) } } .font(.gbSans(.caption)) .buttonStyle(.bordered) .disabled(model.working) } if let suggestion = thread.suggestion { SuggestionView(suggestion: suggestion, thread: thread, model: model) } ForEach(thread.comments) { comment in VStack(alignment: .leading, spacing: 2) { HStack { Text(comment.author) .font(.gbSans(.caption).weight(.semibold)) Text(comment.createdAt, format: .relative(presentation: .named)) .font(.gbSans(.caption2)) .foregroundStyle(.tertiary) } // Review comments carry no format; the web renders them as Markdown. // The thread's first comment holds its suggestion, drawn above. FormattedBodyView(text: thread.suggestion != nil && comment.id == thread.comments.first?.id ? Suggestion.withoutBlock(comment.body) : comment.body, repo: model.repoPath) .font(.gbSans(.subheadline)) } } if !thread.isResolved { HStack { TextField("Reply", text: $replyText, axis: .vertical) .font(.gbSans(.subheadline)) .lineLimit(1...4) Button { let text = replyText replyText = "" Task { await model.reply(to: thread, text) } } label: { Image(systemName: "arrow.up.circle.fill") .font(.title) .frame(minWidth: 44, minHeight: 44) .contentShape(Rectangle()) } .accessibilityLabel("Send reply") .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.working) } } } .padding(.vertical, 4) } } /// A single-purpose branch picker for retarget. `model.branches` is /// `nil` while loading and `model.branchesError` distinguishes a failed /// `repo refs` fetch from a repository that genuinely has no other /// branches — an empty list must not read as a failed load. /// The repository's other open merge requests, one of which will be /// recorded as replacing this one. private struct SupersedeSheet: View { let model: MRDetailViewModel let onPick: (Int64) -> Void @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Group { if let error = model.openMergeRequestsError { ContentUnavailableView { Label("Could not load merge requests", systemImage: "wifi.exclamationmark") } description: { Text(error) } } else if let candidates = model.openMergeRequests { if candidates.isEmpty { ContentUnavailableView { Label("No other open merge requests", systemImage: "arrow.triangle.pull") } description: { Text("Only an open merge request in this repository can supersede this one.") } } else { List(candidates) { mr in Button { onPick(mr.number) } label: { MRRow(mr: mr) } .tint(.primary) } } } else { ProgressView() } } .navigationTitle("Close !\(model.number) in favour of") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } .task { await model.loadOpenMergeRequests() } } } } private struct RetargetSheet: View { let model: MRDetailViewModel let onPick: (String) -> Void @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Group { if let error = model.branchesError { ContentUnavailableView { Label("Could not load branches", systemImage: "wifi.exclamationmark") } description: { Text(error) } } else if let branches = model.branches { if branches.isEmpty { ContentUnavailableView { Label("No other branches", systemImage: "arrow.triangle.branch") } description: { Text("This repository has no other branches to retarget to.") } } else { List(branches, id: \.self) { branch in Button(branch) { onPick(branch) } } } } else { ProgressView() } } .navigationTitle("Retarget !\(model.number)") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } .task { await model.loadBranches() } } } }