gitbay/Views/MRs/MRListView.swift
269 lines · 10031 bytes
4 symbols in this file
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}