import SwiftUI struct MRListView: View { @State private var model: MRListViewModel @State private var createModel: MRCreateViewModel @State private var composing = 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) { ForEach(MRListViewModel.StateFilter.allCases) { filter in Text(filter.rawValue.capitalized).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) } .navigationTitle("Merge Requests") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { composing = true } label: { Image(systemName: "plus") } .accessibilityIdentifier("mr-create-button") } } .sheet(isPresented: $composing) { MRCreateSheet(model: createModel) { composing = false Task { await model.load() } } } .task { await model.load() } .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 = "" 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") } 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") { 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) 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) } .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) }