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
66@MainActor
77final class IssueListViewModel {
88
9 enum StateFilter: String, CaseIterable, Identifiable, Sendable {
10 case open, closed, all
11 var id: String { rawValue }
12 }
13
149 let list: PagedListModel<Issue>
1510 let repoPath: String
1611 private let client: GitbayClient
17 var filter: StateFilter = .open {
12 var filter = IssueFilter() {
1813 didSet {
1914 guard filter != oldValue else { return }
2015 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 }
2226 }
2327 }
2428
@@ -31,13 +35,17 @@ final class IssueListViewModel {
3135 /// not on every reload.
3236 private var labelsLoaded = false
3337
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
3442 init(client: GitbayClient, repoPath: String) {
3543 self.client = client
3644 self.repoPath = repoPath
3745 list = PagedListModel(
3846 client: client,
39 argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue],
40 emptyMessage: "No open issues."
47 argv: ["issue", "list", repoPath] + IssueFilter().flags(),
48 emptyMessage: IssueFilter().emptyMessage
4149 )
4250 }
4351
@@ -52,12 +60,28 @@ final class IssueListViewModel {
5260 colors = LabelColors(labels)
5361 }
5462
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
5570 private func labelList() async -> [IssueLabel] {
5671 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
5772 }
5873
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
5983 private func configureList() {
60 list.argv = ["issue", "list", repoPath, "--state", filter.rawValue]
61 list.emptyMessage = "No \(filter == .all ? "" : filter.rawValue + " ")issues."
84 list.argv = ["issue", "list", repoPath] + filter.flags()
85 list.emptyMessage = filter.emptyMessage
6286 }
6387}
gitbay/MRs/MRListViewModel.swift +37 −12
@@ -6,27 +6,37 @@ import Observation
66@MainActor
77final class MRListViewModel {
88
9 enum StateFilter: String, CaseIterable, Identifiable, Sendable {
10 case open, merged, closed, all
11 var id: String { rawValue }
12 }
13
149 let list: PagedListModel<MergeRequest>
1510 let repoPath: String
16 var filter: StateFilter = .open {
11 private let client: GitbayClient
12 var filter = MRFilter() {
1713 didSet {
1814 guard filter != oldValue else { return }
1915 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 }
2126 }
2227 }
2328
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
2433 init(client: GitbayClient, repoPath: String) {
34 self.client = client
2535 self.repoPath = repoPath
2636 list = PagedListModel(
2737 client: client,
28 argv: ["mr", "list", repoPath, "--state", StateFilter.open.rawValue],
29 emptyMessage: "No open merge requests."
38 argv: ["mr", "list", repoPath] + MRFilter().flags(),
39 emptyMessage: MRFilter().emptyMessage
3040 )
3141 }
3242
@@ -36,9 +46,24 @@ final class MRListViewModel {
3646 await list.reload()
3747 }
3848
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
3965 private func configureList() {
40 list.argv = ["mr", "list", repoPath, "--state", filter.rawValue]
41 list.emptyMessage =
42 "No \(filter == .all ? "" : filter.rawValue + " ")merge requests."
66 list.argv = ["mr", "list", repoPath] + filter.flags()
67 list.emptyMessage = filter.emptyMessage
4368 }
4469}
gitbay/Views/Issues/IssueListView.swift +3 −3
@@ -15,9 +15,9 @@ struct IssueListView: View {
1515
1616 var body: some View {
1717 List {
18 Picker("State", selection: Bindable(model).filter) {
19 ForEach(IssueListViewModel.StateFilter.allCases) { filter in
20 Text(filter.rawValue.capitalized).tag(filter)
18 Picker("State", selection: Bindable(model).filter.state) {
19 ForEach(IssueFilter.State.allCases) { filter in
20 Text(filter.label).tag(filter)
2121 }
2222 }
2323 .pickerStyle(.segmented)
gitbay/Views/MRs/MRListView.swift +3 −3
@@ -13,9 +13,9 @@ struct MRListView: View {
1313
1414 var body: some View {
1515 List {
16 Picker("State", selection: Bindable(model).filter) {
17 ForEach(MRListViewModel.StateFilter.allCases) { filter in
18 Text(filter.rawValue.capitalized).tag(filter)
16 Picker("State", selection: Bindable(model).filter.state) {
17 ForEach(MRFilter.State.allCases) { filter in
18 Text(filter.label).tag(filter)
1919 }
2020 }
2121 .pickerStyle(.segmented)
gitbayTests/ListFilterTests.swift +127
@@ -129,3 +129,130 @@ struct MRFilterTests {
129129 #expect(narrowed.emptyMessage == "No open merge requests match these filters.")
130130 }
131131}
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 {
171171 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
172172 await model.load()
173173
174 model.filter = .merged
174 model.filter.state = .merged
175175 // The reload happens in a spawned task; give it a beat.
176176 try await Task.sleep(for: .milliseconds(300))
177177