Commit 6f9736ba2c

6f9736ba2c113361e5801a4e7feb8cf2a520ff31

parent: 380e448dea

Verified · cmc

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

Both list view models filter and search through their commands

Layout: unified · split

gitbay/Issues/IssueListViewModel.swift +35 −11
@@ -6,19 +6,23 @@ import Observation
6@MainActor 6@MainActor
7final class IssueListViewModel { 7final class IssueListViewModel {
8 8
9 enum StateFilter: String, CaseIterable, Identifiable, Sendable {
10 case open, closed, all
11 var id: String { rawValue }
12 }
13
14 let list: PagedListModel<Issue> 9 let list: PagedListModel<Issue>
15 let repoPath: String 10 let repoPath: String
16 private let client: GitbayClient 11 private let client: GitbayClient
17 var filter: StateFilter = .open { 12 var filter = IssueFilter() {
18 didSet { 13 didSet {
19 guard filter != oldValue else { return } 14 guard filter != oldValue else { return }
20 configureList() 15 configureList()
21 Task { await list.reload() } 16 // Only the text changed: debounce, so typing does not fire a
17 // request per keystroke. Anything else is a picker: reload now.
18 var typed = oldValue
19 typed.search = filter.search
20 if typed == filter {
21 scheduleSearchReload()
22 } else {
23 reloadTask?.cancel()
24 reloadTask = Task { await list.reload() }
25 }
22 } 26 }
23 } 27 }
24 28
@@ -31,13 +35,17 @@ final class IssueListViewModel {
31 /// not on every reload. 35 /// not on every reload.
32 private var labelsLoaded = false 36 private var labelsLoaded = false
33 37
38 /// Open and closed milestones for the filter picker; fetched on first use.
39 private(set) var milestones: [Milestone] = []
40 private var reloadTask: Task<Void, Never>?
41
34 init(client: GitbayClient, repoPath: String) { 42 init(client: GitbayClient, repoPath: String) {
35 self.client = client 43 self.client = client
36 self.repoPath = repoPath 44 self.repoPath = repoPath
37 list = PagedListModel( 45 list = PagedListModel(
38 client: client, 46 client: client,
39 argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue], 47 argv: ["issue", "list", repoPath] + IssueFilter().flags(),
40 emptyMessage: "No open issues." 48 emptyMessage: IssueFilter().emptyMessage
41 ) 49 )
42 } 50 }
43 51
@@ -52,12 +60,28 @@ final class IssueListViewModel {
52 colors = LabelColors(labels) 60 colors = LabelColors(labels)
53 } 61 }
54 62
63 func loadMilestones() async {
64 guard milestones.isEmpty else { return }
65 milestones = (try? await client.readList(
66 ["milestone", "list", repoPath, "--state", "all"], of: Milestone.self
67 )) ?? []
68 }
69
55 private func labelList() async -> [IssueLabel] { 70 private func labelList() async -> [IssueLabel] {
56 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] 71 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
57 } 72 }
58 73
74 private func scheduleSearchReload() {
75 reloadTask?.cancel()
76 reloadTask = Task {
77 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
78 guard !Task.isCancelled else { return }
79 await list.reload()
80 }
81 }
82
59 private func configureList() { 83 private func configureList() {
60 list.argv = ["issue", "list", repoPath, "--state", filter.rawValue] 84 list.argv = ["issue", "list", repoPath] + filter.flags()
61 list.emptyMessage = "No \(filter == .all ? "" : filter.rawValue + " ")issues." 85 list.emptyMessage = filter.emptyMessage
62 } 86 }
63} 87}
gitbay/MRs/MRListViewModel.swift +37 −12
@@ -6,27 +6,37 @@ import Observation
6@MainActor 6@MainActor
7final class MRListViewModel { 7final class MRListViewModel {
8 8
9 enum StateFilter: String, CaseIterable, Identifiable, Sendable {
10 case open, merged, closed, all
11 var id: String { rawValue }
12 }
13
14 let list: PagedListModel<MergeRequest> 9 let list: PagedListModel<MergeRequest>
15 let repoPath: String 10 let repoPath: String
16 var filter: StateFilter = .open { 11 private let client: GitbayClient
12 var filter = MRFilter() {
17 didSet { 13 didSet {
18 guard filter != oldValue else { return } 14 guard filter != oldValue else { return }
19 configureList() 15 configureList()
20 Task { await list.reload() } 16 // Only the text changed: debounce, so typing does not fire a
17 // request per keystroke. Anything else is a picker: reload now.
18 var typed = oldValue
19 typed.search = filter.search
20 if typed == filter {
21 scheduleSearchReload()
22 } else {
23 reloadTask?.cancel()
24 reloadTask = Task { await list.reload() }
25 }
21 } 26 }
22 } 27 }
23 28
29 /// Open and closed milestones for the filter picker; fetched on first use.
30 private(set) var milestones: [Milestone] = []
31 private var reloadTask: Task<Void, Never>?
32
24 init(client: GitbayClient, repoPath: String) { 33 init(client: GitbayClient, repoPath: String) {
34 self.client = client
25 self.repoPath = repoPath 35 self.repoPath = repoPath
26 list = PagedListModel( 36 list = PagedListModel(
27 client: client, 37 client: client,
28 argv: ["mr", "list", repoPath, "--state", StateFilter.open.rawValue], 38 argv: ["mr", "list", repoPath] + MRFilter().flags(),
29 emptyMessage: "No open merge requests." 39 emptyMessage: MRFilter().emptyMessage
30 ) 40 )
31 } 41 }
32 42
@@ -36,9 +46,24 @@ final class MRListViewModel {
36 await list.reload() 46 await list.reload()
37 } 47 }
38 48
49 func loadMilestones() async {
50 guard milestones.isEmpty else { return }
51 milestones = (try? await client.readList(
52 ["milestone", "list", repoPath, "--state", "all"], of: Milestone.self
53 )) ?? []
54 }
55
56 private func scheduleSearchReload() {
57 reloadTask?.cancel()
58 reloadTask = Task {
59 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
60 guard !Task.isCancelled else { return }
61 await list.reload()
62 }
63 }
64
39 private func configureList() { 65 private func configureList() {
40 list.argv = ["mr", "list", repoPath, "--state", filter.rawValue] 66 list.argv = ["mr", "list", repoPath] + filter.flags()
41 list.emptyMessage = 67 list.emptyMessage = filter.emptyMessage
42 "No \(filter == .all ? "" : filter.rawValue + " ")merge requests."
43 } 68 }
44} 69}
gitbay/Views/Issues/IssueListView.swift +3 −3
@@ -15,9 +15,9 @@ struct IssueListView: View {
15 15
16 var body: some View { 16 var body: some View {
17 List { 17 List {
18 Picker("State", selection: Bindable(model).filter) { 18 Picker("State", selection: Bindable(model).filter.state) {
19 ForEach(IssueListViewModel.StateFilter.allCases) { filter in 19 ForEach(IssueFilter.State.allCases) { filter in
20 Text(filter.rawValue.capitalized).tag(filter) 20 Text(filter.label).tag(filter)
21 } 21 }
22 } 22 }
23 .pickerStyle(.segmented) 23 .pickerStyle(.segmented)
gitbay/Views/MRs/MRListView.swift +3 −3
@@ -13,9 +13,9 @@ struct MRListView: View {
13 13
14 var body: some View { 14 var body: some View {
15 List { 15 List {
16 Picker("State", selection: Bindable(model).filter) { 16 Picker("State", selection: Bindable(model).filter.state) {
17 ForEach(MRListViewModel.StateFilter.allCases) { filter in 17 ForEach(MRFilter.State.allCases) { filter in
18 Text(filter.rawValue.capitalized).tag(filter) 18 Text(filter.label).tag(filter)
19 } 19 }
20 } 20 }
21 .pickerStyle(.segmented) 21 .pickerStyle(.segmented)
gitbayTests/ListFilterTests.swift +127
@@ -129,3 +129,130 @@ struct MRFilterTests {
129 #expect(narrowed.emptyMessage == "No open merge requests match these filters.") 129 #expect(narrowed.emptyMessage == "No open merge requests match these filters.")
130 } 130 }
131} 131}
132
133private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
134 let box = StubProtocol.box()
135 let client = GitbayClient(
136 instance: try GitbayInstance(url: "https://gitbay.org"),
137 token: "test-token",
138 session: box.session()
139 )
140 return (client, box)
141}
142
143private let emptyPage = """
144 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
145 """
146private let noLabels = """
147 {"protocol_version":1,"data":[],"exit_code":0}
148 """
149
150/// Every `argv=` value from a request URL, in order.
151private func argvFrom(_ url: URL) -> [String] {
152 URLComponents(url: url, resolvingAgainstBaseURL: false)?
153 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
154}
155
156@MainActor
157struct IssueListFilterWiringTests {
158
159 @Test func theDefaultReadIsOpenIssues() async throws {
160 let (client, stub) = try makeClient()
161 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
162 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
163
164 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
165 await model.load()
166
167 let read = try #require(stub.seen.first { $0.url.absoluteString.contains("argv=issue") })
168 #expect(argvFrom(read.url).prefix(6) == [
169 "issue", "list", "krz/gitbay", "--state", "open", "--limit",
170 ])
171 }
172
173 @Test func aPickerChangeReloadsWithTheNewFlags() async throws {
174 let (client, stub) = try makeClient()
175 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
176 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
177 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
178 await model.load()
179
180 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
181 model.filter.label = "bug"
182 try await Task.sleep(for: .milliseconds(150))
183
184 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
185 let last = try #require(reads.last)
186 #expect(argvFrom(last.url).contains("--label"))
187 #expect(argvFrom(last.url).contains("bug"))
188 // Immediate, not debounced: two reads by now.
189 #expect(reads.count == 2)
190 }
191
192 /// Typing must not fire a request per keystroke.
193 @Test func typingDebouncesIntoOneRead() async throws {
194 let (client, stub) = try makeClient()
195 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
196 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
197 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
198 await model.load()
199
200 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
201 for text in ["c", "cr", "cra", "cras", "crash"] {
202 model.filter.search = text
203 try await Task.sleep(for: .milliseconds(20))
204 }
205 try await Task.sleep(for: .milliseconds(500))
206
207 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
208 #expect(reads.count == 2)
209 #expect(argvFrom(try #require(reads.last).url).contains("crash"))
210 }
211
212 @Test func theEmptyMessageComesFromTheFilter() async throws {
213 let (client, stub) = try makeClient()
214 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
215 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
216 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
217 await model.load()
218
219 guard case .empty(let message) = model.state else {
220 Testing.Issue.record("expected empty, got \(model.state)")
221 return
222 }
223 #expect(message == "No open issues.")
224 }
225}
226
227@MainActor
228struct MRListFilterWiringTests {
229
230 @Test func searchReachesTheCommand() async throws {
231 let (client, stub) = try makeClient()
232 stub.enqueue(.init(status: 200, json: emptyPage))
233 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
234 await model.load()
235
236 stub.enqueue(.init(status: 200, json: emptyPage))
237 model.filter.search = "retarget"
238 try await Task.sleep(for: .milliseconds(500))
239
240 let last = try #require(stub.seen.last)
241 let argv = argvFrom(last.url)
242 #expect(argv.contains("--search"))
243 #expect(argv.contains("retarget"))
244 }
245
246 @Test func sourceGoneReachesTheCommandInItsWireSpelling() async throws {
247 let (client, stub) = try makeClient()
248 stub.enqueue(.init(status: 200, json: emptyPage))
249 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
250 await model.load()
251
252 stub.enqueue(.init(status: 200, json: emptyPage))
253 model.filter.state = .sourceGone
254 try await Task.sleep(for: .milliseconds(150))
255
256 #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone"))
257 }
258}
gitbayTests/MRViewModelTests.swift +1 −1
@@ -171,7 +171,7 @@ struct MRListViewModelTests {
171 let model = MRListViewModel(client: client, repoPath: "krz/gitbay") 171 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
172 await model.load() 172 await model.load()
173 173
174 model.filter = .merged 174 model.filter.state = .merged
175 // The reload happens in a spawned task; give it a beat. 175 // The reload happens in a spawned task; give it a beat.
176 try await Task.sleep(for: .milliseconds(300)) 176 try await Task.sleep(for: .milliseconds(300))
177 177