gitbay/Views/MRs/MRListView.swift

v1.6.2
gitbay-ios/gitbay/Views/MRs/MRListView.swift history · blame · raw

254 lines · 9437 bytes

  1import SwiftUI
  2
  3struct MRListView: View {
  4
  5    @State private var model: MRListViewModel
  6    @State private var createModel: MRCreateViewModel
  7    @State private var composing = false
  8    @State private var filtering = false
  9
 10    init(client: GitbayClient, repo: String) {
 11        _model = State(initialValue: MRListViewModel(client: client, repoPath: repo))
 12        _createModel = State(initialValue: MRCreateViewModel(client: client, repoPath: repo))
 13    }
 14
 15    var body: some View {
 16        List {
 17            Picker("State", selection: Bindable(model).filter.state) {
 18                ForEach(MRFilter.State.allCases) { filter in
 19                    Text(filter.label).tag(filter)
 20                }
 21            }
 22            .pickerStyle(.segmented)
 23            .listRowBackground(Color.clear)
 24            .listRowInsets(EdgeInsets())
 25
 26            ForEach(model.state.value ?? []) { mr in
 27                NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: mr.number)) {
 28                    MRRow(mr: mr)
 29                }
 30            }
 31            PageFooter(list: model.list)
 32        }
 33        .overlay { LoadStateOverlay(state: model.state) }
 34        .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies")
 35        .navigationTitle("Merge Requests")
 36        .navigationBarTitleDisplayMode(.inline)
 37        .toolbar {
 38            ToolbarItem(placement: .topBarTrailing) {
 39                Button {
 40                    filtering = true
 41                } label: {
 42                    Image(systemName: model.filter.isNarrowed
 43                        ? "line.3.horizontal.decrease.circle.fill"
 44                        : "line.3.horizontal.decrease.circle")
 45                }
 46                .accessibilityIdentifier("mr-filter-button")
 47            }
 48            ToolbarItem(placement: .topBarTrailing) {
 49                Button {
 50                    composing = true
 51                } label: {
 52                    Image(systemName: "plus")
 53                }
 54                .accessibilityIdentifier("mr-create-button")
 55            }
 56        }
 57        .sheet(isPresented: $filtering) {
 58            FilterSheet(
 59                title: "Filter Merge Requests",
 60                author: Bindable(model).filter.author,
 61                milestone: Bindable(model).filter.milestone,
 62                milestones: model.milestones,
 63                onClear: {
 64                    let state = model.filter.state
 65                    model.filter = MRFilter(state: state)
 66                }
 67            ) {
 68                LabelFilterSection(
 69                    label: Bindable(model).filter.label,
 70                    labels: model.labels,
 71                    colors: model.colors
 72                )
 73            }
 74            .task {
 75                await model.loadMilestones()
 76                await model.loadLabels()
 77            }
 78        }
 79        .sheet(isPresented: $composing) {
 80            MRCreateSheet(model: createModel) {
 81                composing = false
 82                Task { await model.load() }
 83            }
 84        }
 85        .task { await model.loadIfNeeded() }
 86        .refreshable { await model.load() }
 87    }
 88}
 89
 90/// Source and target are typed, not picked: no command lists branches
 91/// yet (krz/gitbay#45). Target prefills with the default branch.
 92private struct MRCreateSheet: View {
 93
 94    let model: MRCreateViewModel
 95    let onCreated: () -> Void
 96
 97    @Environment(\.dismiss) private var dismiss
 98    @State private var source = ""
 99    @State private var target = ""
100    @State private var title = ""
101    @State private var bodyText = ""
102    @State private var format = "md"
103
104    var body: some View {
105        NavigationStack {
106            Form {
107                Section("Branches") {
108                    TextField("Source branch", text: $source)
109                        .autocorrectionDisabled()
110                        .textInputAutocapitalization(.never)
111                        .accessibilityIdentifier("mr-source")
112                    TextField("Target branch", text: $target)
113                        .autocorrectionDisabled()
114                        .textInputAutocapitalization(.never)
115                        .accessibilityIdentifier("mr-target")
116                }
117                Section("Title") {
118                    TextField("Title", text: $title, axis: .vertical)
119                        .lineLimit(1...3)
120                        .autocorrectionDisabled()
121                        .accessibilityIdentifier("mr-title")
122                }
123                Section("Body") {
124                    TextEditor(text: $bodyText)
125                        .frame(minHeight: 120)
126                        .autocorrectionDisabled()
127                        .accessibilityIdentifier("mr-body")
128                    Picker("Format", selection: $format) {
129                        Text("Markdown").tag("md")
130                        Text("Org").tag("org")
131                    }
132                    .pickerStyle(.segmented)
133                    .accessibilityIdentifier("mr-format")
134                    MarkupPreview(text: bodyText, format: format, identifier: "mr-create-preview")
135                }
136                Section {
137                    Toggle("Draft", isOn: Bindable(model).draft)
138                        .accessibilityIdentifier("mr-draft")
139                } footer: {
140                    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.")
141                }
142                if let error = model.errorMessage {
143                    Section {
144                        GBNotice(error)
145                    }
146                }
147            }
148            .scrollDismissesKeyboard(.interactively)
149            .navigationTitle("New Merge Request")
150            .navigationBarTitleDisplayMode(.inline)
151            .toolbar {
152                ToolbarItem(placement: .cancellationAction) {
153                    Button("Cancel") { dismiss() }
154                }
155                ToolbarItem(placement: .confirmationAction) {
156                    if model.working {
157                        ProgressView()
158                    } else {
159                        Button("Create") {
160                            model.format = format
161                            Task {
162                                if await model.create(
163                                    source: source, target: target,
164                                    title: title, body: bodyText
165                                ) != nil {
166                                    onCreated()
167                                }
168                            }
169                        }
170                        .disabled(
171                            source.trimmingCharacters(in: .whitespaces).isEmpty
172                                || target.trimmingCharacters(in: .whitespaces).isEmpty
173                                || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
174                        )
175                        .accessibilityIdentifier("mr-submit")
176                    }
177                }
178            }
179            .interactiveDismissDisabled(model.working)
180            .task {
181                await model.loadDefaultBranch()
182                if target.isEmpty, let branch = model.defaultBranch {
183                    target = branch
184                }
185            }
186        }
187    }
188}
189
190struct MRRow: View {
191    let mr: MergeRequest
192
193    var body: some View {
194        VStack(alignment: .leading, spacing: 4) {
195            HStack(alignment: .firstTextBaseline, spacing: 6) {
196                Text("!\(mr.number)")
197                    .font(.gbMono(.caption))
198                    .foregroundStyle(.secondary)
199                Text(mr.title)
200                    .font(.gbSans(.subheadline).weight(.medium))
201                    .lineLimit(2)
202            }
203            HStack(spacing: 6) {
204                MRStateBadge(state: mr.state)
205                if mr.draft {
206                    GBChip("draft", .secondary)
207                }
208                Text(mr.source.isEmpty ? "(source gone)" : mr.source)
209                    .lineLimit(1)
210                Image(systemName: "arrow.right")
211                    .font(.gbSans(.caption2))
212                Text(mr.targetRef)
213                Spacer()
214                Text(mr.createdAt, format: .relative(presentation: .named))
215                    .foregroundStyle(.secondary)
216            }
217            .font(.gbSans(.caption))
218            .foregroundStyle(.secondary)
219            if let stackedOn = mr.stackedOn {
220                Text("stacked on !\(stackedOn.number)")
221                    .font(.gbSans(.caption2))
222                    .foregroundStyle(.tertiary)
223            }
224        }
225        .padding(.vertical, 2)
226    }
227}
228
229struct MRStateBadge: View {
230    let state: String
231
232    var body: some View {
233        GBChip(state.replacingOccurrences(of: "_", with: " "), color)
234    }
235
236    /// The web's chip mapping: open=ok, merged=done, closed=bad,
237    /// source_gone=neutral.
238    private var color: Color {
239        switch state {
240        case "open": .gbOK
241        case "merged": .gbDone
242        case "closed": .gbBad
243        default: .secondary
244        }
245    }
246}
247
248/// MR navigation routes, separate from the repo stack's.
249nonisolated enum MRRoute: Hashable {
250    case list(repo: String)
251    case mr(repo: String, number: Int64)
252    case diff(repo: String, number: Int64)
253    case rangeDiff(repo: String, number: Int64, from: String, to: String)
254}