gitbay/Views/MRs/MRListView.swift

v1.0.7
gitbay-ios/gitbay/Views/MRs/MRListView.swift history · blame · raw

243 lines · 9001 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    case rangeDiff(repo: String, number: Int64, from: String, to: String)
243}