gitbay/Views/MRs/MRListView.swift
200 lines · 7089 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
9 init(client: GitbayClient, repo: String) {
10 _model = State(initialValue: MRListViewModel(client: client, repoPath: repo))
11 _createModel = State(initialValue: MRCreateViewModel(client: client, repoPath: repo))
12 }
13
14 var body: some View {
15 List {
16 Picker("State", selection: Bindable(model).filter) {
17 ForEach(MRListViewModel.StateFilter.allCases) { filter in
18 Text(filter.rawValue.capitalized).tag(filter)
19 }
20 }
21 .pickerStyle(.segmented)
22 .listRowBackground(Color.clear)
23 .listRowInsets(EdgeInsets())
24
25 ForEach(model.state.value ?? []) { mr in
26 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: mr.number)) {
27 MRRow(mr: mr)
28 }
29 }
30 PageFooter(list: model.list)
31 }
32 .overlay { LoadStateOverlay(state: model.state) }
33 .navigationTitle("Merge Requests")
34 .navigationBarTitleDisplayMode(.inline)
35 .toolbar {
36 ToolbarItem(placement: .topBarTrailing) {
37 Button {
38 composing = true
39 } label: {
40 Image(systemName: "plus")
41 }
42 .accessibilityIdentifier("mr-create-button")
43 }
44 }
45 .sheet(isPresented: $composing) {
46 MRCreateSheet(model: createModel) {
47 composing = false
48 Task { await model.load() }
49 }
50 }
51 .task { await model.load() }
52 .refreshable { await model.load() }
53 }
54}
55
56/// Source and target are typed, not picked: no command lists branches
57/// yet (krz/gitbay#45). Target prefills with the default branch.
58private struct MRCreateSheet: View {
59
60 let model: MRCreateViewModel
61 let onCreated: () -> Void
62
63 @Environment(\.dismiss) private var dismiss
64 @State private var source = ""
65 @State private var target = ""
66 @State private var title = ""
67 @State private var bodyText = ""
68
69 var body: some View {
70 NavigationStack {
71 Form {
72 Section("Branches") {
73 TextField("Source branch", text: $source)
74 .autocorrectionDisabled()
75 .textInputAutocapitalization(.never)
76 .accessibilityIdentifier("mr-source")
77 TextField("Target branch", text: $target)
78 .autocorrectionDisabled()
79 .textInputAutocapitalization(.never)
80 .accessibilityIdentifier("mr-target")
81 }
82 Section("Title") {
83 TextField("Title", text: $title, axis: .vertical)
84 .lineLimit(1...3)
85 .autocorrectionDisabled()
86 .accessibilityIdentifier("mr-title")
87 }
88 Section("Body") {
89 TextEditor(text: $bodyText)
90 .frame(minHeight: 120)
91 .autocorrectionDisabled()
92 .accessibilityIdentifier("mr-body")
93 }
94 if let error = model.errorMessage {
95 Section {
96 Label(error, systemImage: "exclamationmark.triangle")
97 .foregroundStyle(.red)
98 .font(.subheadline)
99 }
100 }
101 }
102 .navigationTitle("New Merge Request")
103 .navigationBarTitleDisplayMode(.inline)
104 .toolbar {
105 ToolbarItem(placement: .cancellationAction) {
106 Button("Cancel") { dismiss() }
107 }
108 ToolbarItem(placement: .confirmationAction) {
109 if model.working {
110 ProgressView()
111 } else {
112 Button("Create") {
113 Task {
114 if await model.create(
115 source: source, target: target,
116 title: title, body: bodyText
117 ) != nil {
118 onCreated()
119 }
120 }
121 }
122 .disabled(
123 source.trimmingCharacters(in: .whitespaces).isEmpty
124 || target.trimmingCharacters(in: .whitespaces).isEmpty
125 || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
126 )
127 .accessibilityIdentifier("mr-submit")
128 }
129 }
130 }
131 .interactiveDismissDisabled(model.working)
132 .task {
133 await model.loadDefaultBranch()
134 if target.isEmpty, let branch = model.defaultBranch {
135 target = branch
136 }
137 }
138 }
139 }
140}
141
142struct MRRow: View {
143 let mr: MergeRequest
144
145 var body: some View {
146 VStack(alignment: .leading, spacing: 4) {
147 HStack(alignment: .firstTextBaseline, spacing: 6) {
148 Text("!\(mr.number)")
149 .font(.caption.monospaced())
150 .foregroundStyle(.secondary)
151 Text(mr.title)
152 .font(.subheadline.weight(.medium))
153 .lineLimit(2)
154 }
155 HStack(spacing: 6) {
156 MRStateBadge(state: mr.state)
157 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
158 .lineLimit(1)
159 Image(systemName: "arrow.right")
160 .font(.caption2)
161 Text(mr.targetRef)
162 Spacer()
163 Text(mr.createdAt, format: .relative(presentation: .named))
164 .foregroundStyle(.tertiary)
165 }
166 .font(.caption)
167 .foregroundStyle(.secondary)
168 }
169 .padding(.vertical, 2)
170 }
171}
172
173struct MRStateBadge: View {
174 let state: String
175
176 var body: some View {
177 Text(state.replacingOccurrences(of: "_", with: " "))
178 .font(.caption2.weight(.medium))
179 .padding(.horizontal, 6)
180 .padding(.vertical, 1)
181 .background(color.opacity(0.15), in: Capsule())
182 .foregroundStyle(color)
183 }
184
185 private var color: Color {
186 switch state {
187 case "open": .green
188 case "merged": .purple
189 case "closed": .red
190 default: .secondary
191 }
192 }
193}
194
195/// MR navigation routes, separate from the repo stack's.
196nonisolated enum MRRoute: Hashable {
197 case list(repo: String)
198 case mr(repo: String, number: Int64)
199 case diff(repo: String, number: Int64)
200}