Commit 3efd628ddd

3efd628ddd7ee6cbd59716ed7c6ae23ba6355228

parent: 4c43ef244d

Verified · cmc

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

Fix final-review findings in instance search

Keep prior results visible during a refined search instead of
flashing to a spinner on every keystroke; drop the dead clear()
method and its weak test; stop the debounce task from capturing self
strongly and add isolated deinit to cancel it; cover a failed search
and query trimming with tests.

Layout: unified · split

gitbay/Discovery/SearchViewModel.swift +6 −5
@@ -81,20 +81,21 @@ final class SearchViewModel {
81 self.client = client 81 self.client = client
82 } 82 }
83 83
84 func clear() { 84 isolated deinit {
85 searchTask?.cancel() 85 searchTask?.cancel()
86 query = ""
87 } 86 }
88 87
89 private func scheduleSearch() { 88 private func scheduleSearch() {
90 searchTask?.cancel() 89 searchTask?.cancel()
91 guard isSearching else { return } 90 guard isSearching else { return }
92 state = .loading 91 // Keep prior results on screen while a refined search is in
93 searchTask = Task { 92 // flight, the way the repository list does.
93 if state.value == nil { state = .loading }
94 searchTask = Task { [weak self] in
94 // Debounce a person typing, the way the repository list does. 95 // Debounce a person typing, the way the repository list does.
95 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return } 96 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
96 guard !Task.isCancelled else { return } 97 guard !Task.isCancelled else { return }
97 await run() 98 await self?.run()
98 } 99 }
99 } 100 }
100 101
gitbayTests/SearchTests.swift +26 −13
@@ -168,33 +168,46 @@ struct SearchViewModelTests {
168 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "c++"]) 168 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "c++"])
169 } 169 }
170 170
171 @Test func clearingStopsSearchingAndDropsResults() async throws { 171 @Test func nothingFoundIsAnEmptyStateNotAFailure() async throws {
172 let (client, stub) = try makeClient() 172 let (client, stub) = try makeClient()
173 stub.enqueue(.init(status: 200, json: mixedResults)) 173 stub.enqueue(.init(status: 200, json: """
174 {"protocol_version":1,"data":[],"exit_code":0}
175 """))
174 let model = SearchViewModel(client: client) 176 let model = SearchViewModel(client: client)
175 model.query = "parity"
176 try await Task.sleep(for: .milliseconds(600))
177 #expect(model.isSearching)
178 177
179 model.clear() 178 model.query = "zzzznothing"
179 try await Task.sleep(for: .milliseconds(600))
180 180
181 #expect(model.isSearching == false) 181 guard case .empty = model.state else {
182 #expect(model.query.isEmpty) 182 Testing.Issue.record("expected empty, got \(model.state)")
183 return
184 }
183 } 185 }
184 186
185 @Test func nothingFoundIsAnEmptyStateNotAFailure() async throws { 187 @Test func aServerFailureLandsInFailedNotEmpty() async throws {
186 let (client, stub) = try makeClient() 188 let (client, stub) = try makeClient()
187 stub.enqueue(.init(status: 200, json: """ 189 stub.enqueue(.init(status: 200, json: """
188 {"protocol_version":1,"data":[],"exit_code":0} 190 {"protocol_version":1,"error":"boom","exit_code":1}
189 """)) 191 """))
190 let model = SearchViewModel(client: client) 192 let model = SearchViewModel(client: client)
191 193
192 model.query = "zzzznothing" 194 model.query = "parity"
193 try await Task.sleep(for: .milliseconds(600)) 195 try await Task.sleep(for: .milliseconds(600))
194 196
195 guard case .empty = model.state else { 197 guard case .failed = model.state else {
196 Testing.Issue.record("expected empty, got \(model.state)") 198 Testing.Issue.record("expected failed, got \(model.state)")
197 return 199 return
198 } 200 }
199 } 201 }
202
203 @Test func theQueryIsTrimmedBeforeBeingSent() async throws {
204 let (client, stub) = try makeClient()
205 stub.enqueue(.init(status: 200, json: mixedResults))
206 let model = SearchViewModel(client: client)
207
208 model.query = " parity "
209 try await Task.sleep(for: .milliseconds(600))
210
211 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity"])
212 }
200} 213}