Commit 5e81daed1a

5e81daed1ab95c72a601d14bee9c81ac17c5950a

parent: 6f9736ba2c

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 19:20 UTC

Filter and search controls on the issue and merge request lists

Layout: unified · split

gitbay/Views/Issues/IssueListView.swift +32
@@ -5,6 +5,7 @@ struct IssueListView: View {
5 @State private var model: IssueListViewModel 5 @State private var model: IssueListViewModel
6 @State private var createModel: IssueCreateViewModel 6 @State private var createModel: IssueCreateViewModel
7 @State private var composing = false 7 @State private var composing = false
8 @State private var filtering = false
8 @State private var draftTitle = "" 9 @State private var draftTitle = ""
9 @State private var draftBody = "" 10 @State private var draftBody = ""
10 11
@@ -32,9 +33,20 @@ struct IssueListView: View {
32 PageFooter(list: model.list) 33 PageFooter(list: model.list)
33 } 34 }
34 .overlay { LoadStateOverlay(state: model.state) } 35 .overlay { LoadStateOverlay(state: model.state) }
36 .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies")
35 .navigationTitle("Issues") 37 .navigationTitle("Issues")
36 .navigationBarTitleDisplayMode(.inline) 38 .navigationBarTitleDisplayMode(.inline)
37 .toolbar { 39 .toolbar {
40 ToolbarItem(placement: .topBarTrailing) {
41 Button {
42 filtering = true
43 } label: {
44 Image(systemName: model.filter.isNarrowed
45 ? "line.3.horizontal.decrease.circle.fill"
46 : "line.3.horizontal.decrease.circle")
47 }
48 .accessibilityIdentifier("issue-filter-button")
49 }
38 ToolbarItem(placement: .topBarTrailing) { 50 ToolbarItem(placement: .topBarTrailing) {
39 Button { 51 Button {
40 composing = true 52 composing = true
@@ -44,6 +56,26 @@ struct IssueListView: View {
44 .accessibilityIdentifier("issue-create-button") 56 .accessibilityIdentifier("issue-create-button")
45 } 57 }
46 } 58 }
59 .sheet(isPresented: $filtering) {
60 FilterSheet(
61 title: "Filter Issues",
62 author: Bindable(model).filter.author,
63 milestone: Bindable(model).filter.milestone,
64 milestones: model.milestones,
65 onClear: {
66 let state = model.filter.state
67 model.filter = IssueFilter(state: state)
68 }
69 ) {
70 IssueFilterExtra(
71 label: Bindable(model).filter.label,
72 assignee: Bindable(model).filter.assignee,
73 labels: model.labels,
74 colors: model.colors
75 )
76 }
77 .task { await model.loadMilestones() }
78 }
47 .sheet(isPresented: $composing) { 79 .sheet(isPresented: $composing) {
48 ComposeSheet( 80 ComposeSheet(
49 heading: "New Issue", 81 heading: "New Issue",
gitbay/Views/MRs/MRListView.swift +25
@@ -5,6 +5,7 @@ struct MRListView: View {
5 @State private var model: MRListViewModel 5 @State private var model: MRListViewModel
6 @State private var createModel: MRCreateViewModel 6 @State private var createModel: MRCreateViewModel
7 @State private var composing = false 7 @State private var composing = false
8 @State private var filtering = false
8 9
9 init(client: GitbayClient, repo: String) { 10 init(client: GitbayClient, repo: String) {
10 _model = State(initialValue: MRListViewModel(client: client, repoPath: repo)) 11 _model = State(initialValue: MRListViewModel(client: client, repoPath: repo))
@@ -30,9 +31,20 @@ struct MRListView: View {
30 PageFooter(list: model.list) 31 PageFooter(list: model.list)
31 } 32 }
32 .overlay { LoadStateOverlay(state: model.state) } 33 .overlay { LoadStateOverlay(state: model.state) }
34 .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies")
33 .navigationTitle("Merge Requests") 35 .navigationTitle("Merge Requests")
34 .navigationBarTitleDisplayMode(.inline) 36 .navigationBarTitleDisplayMode(.inline)
35 .toolbar { 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 }
36 ToolbarItem(placement: .topBarTrailing) { 48 ToolbarItem(placement: .topBarTrailing) {
37 Button { 49 Button {
38 composing = true 50 composing = true
@@ -42,6 +54,19 @@ struct MRListView: View {
42 .accessibilityIdentifier("mr-create-button") 54 .accessibilityIdentifier("mr-create-button")
43 } 55 }
44 } 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 }
45 .sheet(isPresented: $composing) { 70 .sheet(isPresented: $composing) {
46 MRCreateSheet(model: createModel) { 71 MRCreateSheet(model: createModel) {
47 composing = false 72 composing = false
gitbay/Views/Shared/FilterSheet.swift added +115
@@ -0,0 +1,115 @@
1import SwiftUI
2
3/// The issue and MR lists' filter sheet. A `Form` with the fields both
4/// lists share — author and milestone — plus an `extra` slot for the
5/// rows only issues have, since `mr list` takes neither `--label` nor
6/// `--assignee`.
7struct FilterSheet<Extra: View>: View {
8
9 let title: String
10 @Binding var author: String?
11 @Binding var milestone: String?
12 let milestones: [Milestone]
13 let onClear: () -> Void
14 @ViewBuilder var extra: Extra
15
16 @Environment(\.dismiss) private var dismiss
17
18 var body: some View {
19 NavigationStack {
20 Form {
21 extra
22 Section("Author") {
23 TextField("Username", text: $author.orEmpty)
24 .autocorrectionDisabled()
25 .textInputAutocapitalization(.never)
26 .accessibilityIdentifier("filter-author")
27 }
28 Section("Milestone") {
29 Menu {
30 Button("Any milestone") { milestone = nil }
31 Button("No milestone") { milestone = "none" }
32 ForEach(milestones) { entry in
33 Button(entry.title) { milestone = entry.title }
34 }
35 } label: {
36 HStack {
37 Text(milestone ?? "Any milestone")
38 Spacer()
39 Image(systemName: "chevron.up.chevron.down")
40 .font(.gbSans(.caption2))
41 .foregroundStyle(.secondary)
42 }
43 }
44 .accessibilityIdentifier("filter-milestone-menu")
45 }
46 Section {
47 Button("Clear filters", role: .destructive, action: onClear)
48 }
49 }
50 .navigationTitle(title)
51 .navigationBarTitleDisplayMode(.inline)
52 .toolbar {
53 ToolbarItem(placement: .confirmationAction) {
54 Button("Done") { dismiss() }
55 }
56 }
57 }
58 }
59}
60
61/// The label and assignee rows the issue list's sheet adds through
62/// `FilterSheet`'s `extra` slot.
63struct IssueFilterExtra: View {
64
65 @Binding var label: String?
66 @Binding var assignee: String?
67 let labels: [IssueLabel]
68 let colors: LabelColors
69
70 var body: some View {
71 Section("Label") {
72 Menu {
73 Button("Any label") { label = nil }
74 ForEach(labels) { entry in
75 Button {
76 label = entry.name
77 } label: {
78 Text(entry.name)
79 }
80 }
81 } label: {
82 HStack {
83 if let label {
84 GBChip(label, colors.color(label))
85 } else {
86 Text("Any label")
87 }
88 Spacer()
89 Image(systemName: "chevron.up.chevron.down")
90 .font(.gbSans(.caption2))
91 .foregroundStyle(.secondary)
92 }
93 }
94 .accessibilityIdentifier("filter-label-menu")
95 }
96 Section("Assignee") {
97 TextField("Username", text: $assignee.orEmpty)
98 .autocorrectionDisabled()
99 .textInputAutocapitalization(.never)
100 .accessibilityIdentifier("filter-assignee")
101 }
102 }
103}
104
105extension Binding where Value == String? {
106 /// Bridges an optional filter field to a plain `TextField`: empty
107 /// text writes back `nil` rather than `""`, since the two are
108 /// distinct filter values (no flag sent versus `--flag ""`).
109 var orEmpty: Binding<String> {
110 Binding<String>(
111 get: { self.wrappedValue ?? "" },
112 set: { self.wrappedValue = $0.isEmpty ? nil : $0 }
113 )
114 }
115}