import SwiftUI struct MRListView: View { @State private var model: MRListViewModel @State private var createModel: MRCreateViewModel @State private var composing = false @State private var filtering = false init(client: GitbayClient, repo: String) { _model = State(initialValue: MRListViewModel(client: client, repoPath: repo)) _createModel = State(initialValue: MRCreateViewModel(client: client, repoPath: repo)) } var body: some View { List { Picker("State", selection: Bindable(model).filter.state) { ForEach(MRFilter.State.allCases) { filter in Text(filter.label).tag(filter) } } .pickerStyle(.segmented) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) ForEach(model.state.value ?? []) { mr in NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: mr.number)) { MRRow(mr: mr) } } PageFooter(list: model.list) } .overlay { LoadStateOverlay(state: model.state) } .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies") .navigationTitle("Merge Requests") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { filtering = true } label: { Image(systemName: model.filter.isNarrowed ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") } .accessibilityIdentifier("mr-filter-button") } ToolbarItem(placement: .topBarTrailing) { Button { composing = true } label: { Image(systemName: "plus") } .accessibilityIdentifier("mr-create-button") } } .sheet(isPresented: $filtering) { FilterSheet( title: "Filter Merge Requests", author: Bindable(model).filter.author, milestone: Bindable(model).filter.milestone, milestones: model.milestones, onClear: { let state = model.filter.state model.filter = MRFilter(state: state) } ) { LabelFilterSection( label: Bindable(model).filter.label, labels: model.labels, colors: model.colors ) } .task { await model.loadMilestones() await model.loadLabels() } } .sheet(isPresented: $composing) { MRCreateSheet(model: createModel) { composing = false Task { await model.load() } } } .task { await model.loadIfNeeded() } .refreshable { await model.load() } } } /// Source and target are typed, not picked: no command lists branches /// yet (krz/gitbay#45). Target prefills with the default branch. private struct MRCreateSheet: View { let model: MRCreateViewModel let onCreated: () -> Void @Environment(\.dismiss) private var dismiss @State private var source = "" @State private var target = "" @State private var title = "" @State private var bodyText = "" @State private var format = "md" var body: some View { NavigationStack { Form { Section("Branches") { TextField("Source branch", text: $source) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("mr-source") TextField("Target branch", text: $target) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("mr-target") } Section("Title") { TextField("Title", text: $title, axis: .vertical) .lineLimit(1...3) .autocorrectionDisabled() .accessibilityIdentifier("mr-title") } Section("Body") { TextEditor(text: $bodyText) .frame(minHeight: 120) .autocorrectionDisabled() .accessibilityIdentifier("mr-body") Picker("Format", selection: $format) { Text("Markdown").tag("md") Text("Org").tag("org") } .pickerStyle(.segmented) .accessibilityIdentifier("mr-format") MarkupPreview(text: bodyText, format: format, identifier: "mr-create-preview") } Section { Toggle("Draft", isOn: Bindable(model).draft) .accessibilityIdentifier("mr-draft") } footer: { Text("A draft merge request is open but not asking for review: it won't merge and won't show up in a review queue.") } if let error = model.errorMessage { Section { GBNotice(error) } } } .navigationTitle("New Merge Request") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Create") { model.format = format Task { if await model.create( source: source, target: target, title: title, body: bodyText ) != nil { onCreated() } } } .disabled( source.trimmingCharacters(in: .whitespaces).isEmpty || target.trimmingCharacters(in: .whitespaces).isEmpty || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ) .accessibilityIdentifier("mr-submit") } } } .interactiveDismissDisabled(model.working) .task { await model.loadDefaultBranch() if target.isEmpty, let branch = model.defaultBranch { target = branch } } } } } struct MRRow: View { let mr: MergeRequest var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text("!\(mr.number)") .font(.gbMono(.caption)) .foregroundStyle(.secondary) Text(mr.title) .font(.gbSans(.subheadline).weight(.medium)) .lineLimit(2) } 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) Spacer() Text(mr.createdAt, format: .relative(presentation: .named)) .foregroundStyle(.secondary) } .font(.gbSans(.caption)) .foregroundStyle(.secondary) if let stackedOn = mr.stackedOn { Text("stacked on !\(stackedOn.number)") .font(.gbSans(.caption2)) .foregroundStyle(.tertiary) } } .padding(.vertical, 2) } } struct MRStateBadge: View { let state: String var body: some View { GBChip(state.replacingOccurrences(of: "_", with: " "), color) } /// The web's chip mapping: open=ok, merged=done, closed=bad, /// source_gone=neutral. private var color: Color { switch state { case "open": .gbOK case "merged": .gbDone case "closed": .gbBad default: .secondary } } } /// MR navigation routes, separate from the repo stack's. nonisolated enum MRRoute: Hashable { case list(repo: String) case mr(repo: String, number: Int64) case diff(repo: String, number: Int64) case rangeDiff(repo: String, number: Int64, from: String, to: String) }