Commit ec160366ca

ec160366ca032df81b78570b16fc3651562503c7

parent: 321b57d858

Verified · cmc

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

Instance-wide search across repositories, issues and merge requests

Layout: unified · split

gitbay/Discovery/SearchViewModel.swift added +116
@@ -0,0 +1,116 @@
1import Foundation
2import Observation
3
4/// One row of `search` — a repository, an issue or a merge request.
5/// Everything but `kind`, `repo` and `title` is omitempty on the wire: a
6/// repository result carries none of it.
7nonisolated struct SearchResult: Decodable, Sendable, Hashable, Identifiable {
8 let kind: String
9 let repo: String
10 let number: Int64?
11 let title: String
12 let author: String?
13 let state: String?
14 let updatedAt: Date?
15
16 enum CodingKeys: String, CodingKey {
17 case kind, repo, number, title, author, state
18 case updatedAt = "updated_at"
19 }
20
21 /// Unique across kinds: a repo row and an issue row in the same
22 /// repository would otherwise share an identity.
23 var id: String { "\(kind)/\(repo)/\(number.map(String.init) ?? "")" }
24
25 var isOpen: Bool { state == "open" }
26}
27
28/// What `search` is narrowed to. `all` is the absence of `--kind`.
29nonisolated enum SearchKind: String, CaseIterable, Identifiable, Sendable {
30 case all, repo, issue, mr
31
32 var id: String { rawValue }
33
34 var label: String {
35 switch self {
36 case .all: "All"
37 case .repo: "Repos"
38 case .issue: "Issues"
39 case .mr: "MRs"
40 }
41 }
42
43 var flag: [String] { self == .all ? [] : ["--kind", rawValue] }
44}
45
46/// `search <query> [--kind ...]` across everything the caller can read.
47/// Not paginated — the command takes no cursor.
48@Observable
49@MainActor
50final class SearchViewModel {
51
52 var query = "" {
53 didSet {
54 guard query != oldValue else { return }
55 scheduleSearch()
56 }
57 }
58
59 /// A picker, so it re-searches at once rather than debouncing.
60 var kind: SearchKind = .all {
61 didSet {
62 guard kind != oldValue, isSearching else { return }
63 searchTask?.cancel()
64 searchTask = Task { await run() }
65 }
66 }
67
68 private(set) var state: LoadState<[SearchResult]> = .loading
69
70 /// True once a non-blank query is present. The Explore listing shows
71 /// through whenever this is false.
72 var isSearching: Bool {
73 !query.trimmingCharacters(in: .whitespaces).isEmpty
74 }
75
76 private let client: GitbayClient
77 private var searchTask: Task<Void, Never>?
78
79 init(client: GitbayClient) {
80 self.client = client
81 }
82
83 func clear() {
84 searchTask?.cancel()
85 query = ""
86 }
87
88 private func scheduleSearch() {
89 searchTask?.cancel()
90 guard isSearching else { return }
91 state = .loading
92 searchTask = Task {
93 // Debounce a person typing, the way the repository list does.
94 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
95 guard !Task.isCancelled else { return }
96 await run()
97 }
98 }
99
100 private func run() async {
101 let text = query.trimmingCharacters(in: .whitespaces)
102 guard !text.isEmpty else { return }
103 do {
104 // Passed through unquoted: the server quotes FTS5 term by
105 // term, so an operator is a word to match, not a syntax error.
106 let results = try await client.readList(
107 ["search", text] + kind.flag, of: SearchResult.self
108 )
109 guard !Task.isCancelled else { return }
110 state = results.isEmpty ? .empty("Nothing matches “\(text)”.") : .loaded(results)
111 } catch {
112 guard !Task.isCancelled else { return }
113 state = .from(error)
114 }
115 }
116}
gitbayTests/SearchTests.swift added +200
@@ -0,0 +1,200 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argvFrom(_ url: URL) -> [String] {
16 URLComponents(url: url, resolvingAgainstBaseURL: false)?
17 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
18}
19
20private let mixedResults = """
21 {"protocol_version":1,"data":[\
22 {"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\
23 {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\
24 "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52.595Z"},\
25 {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\
26 "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13.062Z"}\
27 ],"exit_code":0}
28 """
29
30struct SearchResultDecodingTests {
31
32 /// Matches the established direct-decode pattern in
33 /// `DiscoveryTests.swift`: a plain decoder with `.iso8601` dates.
34 private func decode(_ json: String) throws -> [SearchResult] {
35 let decoder = JSONDecoder()
36 decoder.dateDecodingStrategy = .iso8601
37 return try decoder.decode([SearchResult].self, from: Data(json.utf8))
38 }
39
40 private let rows = """
41 [{"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\
42 {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\
43 "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52Z"},\
44 {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\
45 "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13Z"}]
46 """
47
48 @Test func decodesAllThreeKinds() throws {
49 let results = try decode(rows)
50 #expect(results.map(\.kind) == ["repo", "issue", "mr"])
51 #expect(results[1].number == 168)
52 #expect(results[1].author == "cmc")
53 #expect(results[1].state == "closed")
54 }
55
56 /// A repository result carries no number, author, state or time —
57 /// they are omitempty and absent, not null.
58 @Test func aRepositoryResultHasNoNumberOrAuthor() throws {
59 let repo = try #require(try decode(rows).first)
60 #expect(repo.number == nil)
61 #expect(repo.author == nil)
62 #expect(repo.state == nil)
63 #expect(repo.updatedAt == nil)
64 }
65
66 /// A repo row and an issue row in the same repository must not
67 /// collide, or SwiftUI reuses one for the other.
68 @Test func idsAreUniqueAcrossKinds() throws {
69 let ids = try decode(rows).map(\.id)
70 #expect(Set(ids).count == ids.count)
71 }
72}
73
74struct SearchKindTests {
75
76 @Test func allSendsNoFlag() {
77 #expect(SearchKind.all.flag == [])
78 }
79
80 @Test func theOtherThreeSendTheirWireSpelling() {
81 #expect(SearchKind.repo.flag == ["--kind", "repo"])
82 #expect(SearchKind.issue.flag == ["--kind", "issue"])
83 #expect(SearchKind.mr.flag == ["--kind", "mr"])
84 }
85
86 @Test func everyKindTheCommandAcceptsIsOffered() {
87 #expect(SearchKind.allCases.map(\.rawValue) == ["all", "repo", "issue", "mr"])
88 }
89}
90
91@MainActor
92struct SearchViewModelTests {
93
94 @Test func anEmptyQuerySearchesNothing() async throws {
95 let (client, stub) = try makeClient()
96 let model = SearchViewModel(client: client)
97
98 model.query = " "
99 try await Task.sleep(for: .milliseconds(500))
100
101 #expect(stub.seen.isEmpty)
102 #expect(model.isSearching == false)
103 }
104
105 @Test func aQueryReachesTheSearchCommand() async throws {
106 let (client, stub) = try makeClient()
107 stub.enqueue(.init(status: 200, json: mixedResults))
108 let model = SearchViewModel(client: client)
109
110 model.query = "parity"
111 try await Task.sleep(for: .milliseconds(600))
112
113 let read = try #require(stub.seen.last)
114 #expect(argvFrom(read.url) == ["search", "parity"])
115 #expect(model.state.value?.count == 3)
116 #expect(model.isSearching)
117 }
118
119 @Test func typingDebouncesIntoOneRequest() async throws {
120 let (client, stub) = try makeClient()
121 stub.enqueue(.init(status: 200, json: mixedResults))
122 let model = SearchViewModel(client: client)
123
124 for text in ["p", "pa", "par", "pari", "parity"] { model.query = text }
125 try await Task.sleep(for: .milliseconds(700))
126
127 #expect(stub.seen.count == 1)
128 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity"])
129 }
130
131 @Test func narrowingByKindAddsTheFlag() async throws {
132 let (client, stub) = try makeClient()
133 stub.enqueue(.init(status: 200, json: mixedResults))
134 let model = SearchViewModel(client: client)
135 model.query = "parity"
136 try await Task.sleep(for: .milliseconds(600))
137
138 stub.enqueue(.init(status: 200, json: mixedResults))
139 model.kind = .issue
140 try await Task.sleep(for: .milliseconds(150))
141
142 #expect(argvFrom(try #require(stub.seen.last).url)
143 == ["search", "parity", "--kind", "issue"])
144 // A picker, not typing: two requests by now, not one.
145 #expect(stub.seen.count == 2)
146 }
147
148 /// Changing the kind with no query must not search.
149 @Test func kindAloneSearchesNothing() async throws {
150 let (client, stub) = try makeClient()
151 let model = SearchViewModel(client: client)
152
153 model.kind = .mr
154 try await Task.sleep(for: .milliseconds(400))
155
156 #expect(stub.seen.isEmpty)
157 }
158
159 /// The server quotes FTS5 terms itself, so an operator is a word.
160 @Test func theQueryIsPassedThroughUnquoted() async throws {
161 let (client, stub) = try makeClient()
162 stub.enqueue(.init(status: 200, json: mixedResults))
163 let model = SearchViewModel(client: client)
164
165 model.query = "c++"
166 try await Task.sleep(for: .milliseconds(600))
167
168 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "c++"])
169 }
170
171 @Test func clearingStopsSearchingAndDropsResults() async throws {
172 let (client, stub) = try makeClient()
173 stub.enqueue(.init(status: 200, json: mixedResults))
174 let model = SearchViewModel(client: client)
175 model.query = "parity"
176 try await Task.sleep(for: .milliseconds(600))
177 #expect(model.isSearching)
178
179 model.clear()
180
181 #expect(model.isSearching == false)
182 #expect(model.query.isEmpty)
183 }
184
185 @Test func nothingFoundIsAnEmptyStateNotAFailure() async throws {
186 let (client, stub) = try makeClient()
187 stub.enqueue(.init(status: 200, json: """
188 {"protocol_version":1,"data":[],"exit_code":0}
189 """))
190 let model = SearchViewModel(client: client)
191
192 model.query = "zzzznothing"
193 try await Task.sleep(for: .milliseconds(600))
194
195 guard case .empty = model.state else {
196 Testing.Issue.record("expected empty, got \(model.state)")
197 return
198 }
199 }
200}