gitbay/Views/MRs/MRListView.swift
242 lines · 8927 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 .task { await model.loadMilestones() }
69 }
70 .sheet(isPresented: $composing) {
71 MRCreateSheet(model: createModel) {
72 composing = false
73 Task { await model.load() }
74 }
75 }
76 .task { await model.load() }
77 .refreshable { await model.load() }
78 }
79}
80
81/// Source and target are typed, not picked: no command lists branches
82/// yet (krz/gitbay#45). Target prefills with the default branch.
83private struct MRCreateSheet: View {
84
85 let model: MRCreateViewModel
86 let onCreated: () -> Void
87
88 @Environment(\.dismiss) private var dismiss
89 @State private var source = ""
90 @State private var target = ""
91 @State private var title = ""
92 @State private var bodyText = ""
93 @State private var format = "md"
94
95 var body: some View {
96 NavigationStack {
97 Form {
98 Section("Branches") {
99 TextField("Source branch", text: $source)
100 .autocorrectionDisabled()
101 .textInputAutocapitalization(.never)
102 .accessibilityIdentifier("mr-source")
103 TextField("Target branch", text: $target)
104 .autocorrectionDisabled()
105 .textInputAutocapitalization(.never)
106 .accessibilityIdentifier("mr-target")
107 }
108 Section("Title") {
109 TextField("Title", text: $title, axis: .vertical)
110 .lineLimit(1...3)
111 .autocorrectionDisabled()
112 .accessibilityIdentifier("mr-title")
113 }
114 Section("Body") {
115 TextEditor(text: $bodyText)
116 .frame(minHeight: 120)
117 .autocorrectionDisabled()
118 .accessibilityIdentifier("mr-body")
119 Picker("Format", selection: $format) {
120 Text("Markdown").tag("md")
121 Text("Org").tag("org")
122 }
123 .pickerStyle(.segmented)
124 .accessibilityIdentifier("mr-format")
125 }
126 Section {
127 Toggle("Draft", isOn: Bindable(model).draft)
128 .accessibilityIdentifier("mr-draft")
129 } footer: {
130 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.")
131 }
132 if let error = model.errorMessage {
133 Section {
134 GBNotice(error)
135 }
136 }
137 }
138 .navigationTitle("New Merge Request")
139 .navigationBarTitleDisplayMode(.inline)
140 .toolbar {
141 ToolbarItem(placement: .cancellationAction) {
142 Button("Cancel") { dismiss() }
143 }
144 ToolbarItem(placement: .confirmationAction) {
145 if model.working {
146 ProgressView()
147 } else {
148 Button("Create") {
149 model.format = format
150 Task {
151 if await model.create(
152 source: source, target: target,
153 title: title, body: bodyText
154 ) != nil {
155 onCreated()
156 }
157 }
158 }
159 .disabled(
160 source.trimmingCharacters(in: .whitespaces).isEmpty
161 || target.trimmingCharacters(in: .whitespaces).isEmpty
162 || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
163 )
164 .accessibilityIdentifier("mr-submit")
165 }
166 }
167 }
168 .interactiveDismissDisabled(model.working)
169 .task {
170 await model.loadDefaultBranch()
171 if target.isEmpty, let branch = model.defaultBranch {
172 target = branch
173 }
174 }
175 }
176 }
177}
178
179struct MRRow: View {
180 let mr: MergeRequest
181
182 var body: some View {
183 VStack(alignment: .leading, spacing: 4) {
184 HStack(alignment: .firstTextBaseline, spacing: 6) {
185 Text("!\(mr.number)")
186 .font(.gbMono(.caption))
187 .foregroundStyle(.secondary)
188 Text(mr.title)
189 .font(.gbSans(.subheadline).weight(.medium))
190 .lineLimit(2)
191 }
192 HStack(spacing: 6) {
193 MRStateBadge(state: mr.state)
194 if mr.draft {
195 GBChip("draft", .secondary)
196 }
197 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
198 .lineLimit(1)
199 Image(systemName: "arrow.right")
200 .font(.gbSans(.caption2))
201 Text(mr.targetRef)
202 Spacer()
203 Text(mr.createdAt, format: .relative(presentation: .named))
204 .foregroundStyle(.secondary)
205 }
206 .font(.gbSans(.caption))
207 .foregroundStyle(.secondary)
208 if let stackedOn = mr.stackedOn {
209 Text("stacked on !\(stackedOn.number)")
210 .font(.gbSans(.caption2))
211 .foregroundStyle(.tertiary)
212 }
213 }
214 .padding(.vertical, 2)
215 }
216}
217
218struct MRStateBadge: View {
219 let state: String
220
221 var body: some View {
222 GBChip(state.replacingOccurrences(of: "_", with: " "), color)
223 }
224
225 /// The web's chip mapping: open=ok, merged=done, closed=bad,
226 /// source_gone=neutral.
227 private var color: Color {
228 switch state {
229 case "open": .gbOK
230 case "merged": .gbDone
231 case "closed": .gbBad
232 default: .secondary
233 }
234 }
235}
236
237/// MR navigation routes, separate from the repo stack's.
238nonisolated enum MRRoute: Hashable {
239 case list(repo: String)
240 case mr(repo: String, number: Int64)
241 case diff(repo: String, number: Int64)
242}