gitbay/Views/MRs/MRListView.swift
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}