gitbay/Views/MRs/MRListView.swift

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

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