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 {
8181 self.client = client
8282 }
8383
84 func clear() {
84 isolated deinit {
8585 searchTask?.cancel()
86 query = ""
8786 }
8887
8988 private func scheduleSearch() {
9089 searchTask?.cancel()
9190 guard isSearching else { return }
92 state = .loading
93 searchTask = Task {
91 // Keep prior results on screen while a refined search is in
92 // flight, the way the repository list does.
93 if state.value == nil { state = .loading }
94 searchTask = Task { [weak self] in
9495 // Debounce a person typing, the way the repository list does.
9596 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
9697 guard !Task.isCancelled else { return }
97 await run()
98 await self?.run()
9899 }
99100 }
100101
gitbayTests/SearchTests.swift +26 −13
@@ -168,33 +168,46 @@ struct SearchViewModelTests {
168168 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "c++"])
169169 }
170170
171 @Test func clearingStopsSearchingAndDropsResults() async throws {
171 @Test func nothingFoundIsAnEmptyStateNotAFailure() async throws {
172172 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 """))
174176 let model = SearchViewModel(client: client)
175 model.query = "parity"
176 try await Task.sleep(for: .milliseconds(600))
177 #expect(model.isSearching)
178177
179 model.clear()
178 model.query = "zzzznothing"
179 try await Task.sleep(for: .milliseconds(600))
180180
181 #expect(model.isSearching == false)
182 #expect(model.query.isEmpty)
181 guard case .empty = model.state else {
182 Testing.Issue.record("expected empty, got \(model.state)")
183 return
184 }
183185 }
184186
185 @Test func nothingFoundIsAnEmptyStateNotAFailure() async throws {
187 @Test func aServerFailureLandsInFailedNotEmpty() async throws {
186188 let (client, stub) = try makeClient()
187189 stub.enqueue(.init(status: 200, json: """
188 {"protocol_version":1,"data":[],"exit_code":0}
190 {"protocol_version":1,"error":"boom","exit_code":1}
189191 """))
190192 let model = SearchViewModel(client: client)
191193
192 model.query = "zzzznothing"
194 model.query = "parity"
193195 try await Task.sleep(for: .milliseconds(600))
194196
195 guard case .empty = model.state else {
196 Testing.Issue.record("expected empty, got \(model.state)")
197 guard case .failed = model.state else {
198 Testing.Issue.record("expected failed, got \(model.state)")
197199 return
198200 }
199201 }
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 }
200213}