gitbay/Views/MRs/MRListView.swift
195 lines · 6868 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 GBNotice(error)
97 }
98 }
99 }
100 .navigationTitle("New Merge Request")
101 .navigationBarTitleDisplayMode(.inline)
102 .toolbar {
103 ToolbarItem(placement: .cancellationAction) {
104 Button("Cancel") { dismiss() }
105 }
106 ToolbarItem(placement: .confirmationAction) {
107 if model.working {
108 ProgressView()
109 } else {
110 Button("Create") {
111 Task {
112 if await model.create(
113 source: source, target: target,
114 title: title, body: bodyText
115 ) != nil {
116 onCreated()
117 }
118 }
119 }
120 .disabled(
121 source.trimmingCharacters(in: .whitespaces).isEmpty
122 || target.trimmingCharacters(in: .whitespaces).isEmpty
123 || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
124 )
125 .accessibilityIdentifier("mr-submit")
126 }
127 }
128 }
129 .interactiveDismissDisabled(model.working)
130 .task {
131 await model.loadDefaultBranch()
132 if target.isEmpty, let branch = model.defaultBranch {
133 target = branch
134 }
135 }
136 }
137 }
138}
139
140struct MRRow: View {
141 let mr: MergeRequest
142
143 var body: some View {
144 VStack(alignment: .leading, spacing: 4) {
145 HStack(alignment: .firstTextBaseline, spacing: 6) {
146 Text("!\(mr.number)")
147 .font(.gbMono(.caption))
148 .foregroundStyle(.secondary)
149 Text(mr.title)
150 .font(.gbSans(.subheadline).weight(.medium))
151 .lineLimit(2)
152 }
153 HStack(spacing: 6) {
154 MRStateBadge(state: mr.state)
155 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
156 .lineLimit(1)
157 Image(systemName: "arrow.right")
158 .font(.gbSans(.caption2))
159 Text(mr.targetRef)
160 Spacer()
161 Text(mr.createdAt, format: .relative(presentation: .named))
162 .foregroundStyle(.secondary)
163 }
164 .font(.gbSans(.caption))
165 .foregroundStyle(.secondary)
166 }
167 .padding(.vertical, 2)
168 }
169}
170
171struct MRStateBadge: View {
172 let state: String
173
174 var body: some View {
175 GBChip(state.replacingOccurrences(of: "_", with: " "), color)
176 }
177
178 /// The web's chip mapping: open=ok, merged=done, closed=bad,
179 /// source_gone=neutral.
180 private var color: Color {
181 switch state {
182 case "open": .gbOK
183 case "merged": .gbDone
184 case "closed": .gbBad
185 default: .secondary
186 }
187 }
188}
189
190/// MR navigation routes, separate from the repo stack's.
191nonisolated enum MRRoute: Hashable {
192 case list(repo: String)
193 case mr(repo: String, number: Int64)
194 case diff(repo: String, number: Int64)
195}