Commit c23b28d133

c23b28d133648da697cc89348e0a99f3d86a709c

parent: 9a10b53c43

Unsigned

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

Search the instance from the Explore tab (!49)

`search` covers repositories plus the title and body of every issue and
merge request the caller can read. The Explore tab keeps its listing when
the field is empty and swaps in results when it is not, with a segmented
control narrowing to one kind — `all` being the absence of `--kind`,
which the command would reject.

The query is passed through unquoted, since the server quotes FTS5 term
by term and an operator is a word to match. Typing debounces at 300ms and
leaves prior results on screen while the refined query is in flight;
changing the kind re-searches at once. `search` is not paginated, so it
reads directly rather than through the paged list.

A repository result carries no number, author, state or time, so those
decode as optional and a row without a number renders untappable rather
than forcing a route out of nothing.

Closes the `search issues and merge requests` parity row.

Layout: unified · split

docs/superpowers/plans/2026-09-06-mr03-instance-search.md added +496
@@ -0,0 +1,496 @@
1# MR 3: Instance-wide search Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close the `search issues and merge requests` parity row — one query across repositories, issues and merge requests, from the Explore tab.
6
7**Architecture:** `search` is a separate command from `explore`, so the Explore screen gains a second mode rather than a filter: an empty query keeps the existing `explore` paged listing; a typed query swaps in `search` results, with a segmented control narrowing to one kind. A `SearchResult` wire model covers all three kinds in one row shape, and each row routes to the destination its kind implies.
8
9**Tech Stack:** Swift 6, default `MainActor` isolation, iOS 26.5, SwiftUI, Swift Testing, `@Observable`.
10
11**Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md`
12
13## Global Constraints
14
15- Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s.
16- Swift Testing only — never XCTest.
17- `gitbayTests` is hermetic and offline; network goes through `StubProtocol`.
18- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit.
19- The label model is `IssueLabel`, never `Label`.
20- Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer.
21- Never commit to `main`.
22
23**The command, verbatim from the registry:**
24
25```
26search <query> [--kind repo|issue|mr]
27```
28
29Two things follow. It takes **no `--limit` and no `--cursor`** — it is not paginated, so it uses `client.readList`, never `readPage` or `PagedListModel`. And `--kind` takes exactly one of three values; omitting it returns all three kinds.
30
31**The result shape**, from `internal/control/search.go`:
32
33```json
34{"kind":"issue","repo":"krz/gitbay","number":168,"title":"...","author":"cmc",
35 "state":"closed","updated_at":"2026-09-06T16:53:52.595Z"}
36```
37
38`kind` and `repo` and `title` are always present. `number`, `author`, `state` and `updated_at` are `omitempty` — a repository result carries none of them. Decoding must treat all four as optional; a repo row with no `number` is the common case, not an error.
39
40**Build and test command:**
41
42```bash
43xcodebuild -project gitbay.xcodeproj -scheme gitbay \
44 -destination 'platform=iOS Simulator,name=iPhone 17' \
45 test -only-testing:gitbayTests
46```
47
48Baseline: **243 passed, 0 failed, 1 skipped** (`LiveInstanceTests` — expected).
49
50---
51
52## File Structure
53
54| File | Responsibility |
55|------|----------------|
56| `gitbay/Discovery/SearchViewModel.swift` (create) | `SearchResult` wire model, `SearchKind`, and the query/debounce view model |
57| `gitbayTests/SearchTests.swift` (create) | Every test in this plan |
58| `gitbay/Views/Discovery/ExploreView.swift` (modify) | `.searchable`, kind picker, result rows, routing |
59
60---
61
62### Task 1: The search model and view model
63
64**Files:**
65- Create: `gitbay/Discovery/SearchViewModel.swift`
66- Test: `gitbayTests/SearchTests.swift`
67
68**Interfaces produced:**
69- `nonisolated struct SearchResult: Decodable, Sendable, Hashable, Identifiable` — `kind: String`, `repo: String`, `number: Int64?`, `title: String`, `author: String?`, `state: String?`, `updatedAt: Date?`; `var id: String`
70- `nonisolated enum SearchKind: String, CaseIterable, Identifiable, Sendable` — `all`, `repo`, `issue`, `mr`; `var label: String`; `var flag: [String]`
71- `@Observable @MainActor final class SearchViewModel` — `query: String`, `kind: SearchKind`, `state: LoadState<[SearchResult]>`, `var isSearching: Bool`, `func clear()`
72
73Behaviour, each pinned by a test:
741. An empty or whitespace-only query issues **no request at all** and leaves `isSearching` false. The Explore listing stays on screen; searching nothing is not a search.
752. Typing debounces at 300ms, matching `RepoListViewModel` and the list filters — one request per pause, not per keystroke.
763. `kind == .all` sends **no `--kind` flag**; the other three send `--kind repo|issue|mr` with the wire spelling.
774. Changing the kind while a query is present re-searches **immediately** — it is a picker, not typing.
785. The query is passed through **unquoted**. The server quotes FTS5 term by term, so `c++` is a word to match.
796. `id` is unique across kinds and repos — a repo row and an issue row in the same repository must not collide.
80
81- [ ] **Step 1: Write the failing tests**
82
83Create `gitbayTests/SearchTests.swift`:
84
85```swift
86import Foundation
87import Testing
88@testable import gitbay
89
90private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
91 let box = StubProtocol.box()
92 let client = GitbayClient(
93 instance: try GitbayInstance(url: "https://gitbay.org"),
94 token: "test-token",
95 session: box.session()
96 )
97 return (client, box)
98}
99
100private func argvFrom(_ url: URL) -> [String] {
101 URLComponents(url: url, resolvingAgainstBaseURL: false)?
102 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
103}
104
105private let mixedResults = """
106 {"protocol_version":1,"data":[\
107 {"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\
108 {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\
109 "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52.595Z"},\
110 {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\
111 "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13.062Z"}\
112 ],"exit_code":0}
113 """
114
115struct SearchResultDecodingTests {
116
117 /// Matches the established direct-decode pattern in
118 /// `DiscoveryTests.swift`: a plain decoder with `.iso8601` dates.
119 private func decode(_ json: String) throws -> [SearchResult] {
120 let decoder = JSONDecoder()
121 decoder.dateDecodingStrategy = .iso8601
122 return try decoder.decode([SearchResult].self, from: Data(json.utf8))
123 }
124
125 private let rows = """
126 [{"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\
127 {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\
128 "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52Z"},\
129 {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\
130 "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13Z"}]
131 """
132
133 @Test func decodesAllThreeKinds() throws {
134 let results = try decode(rows)
135 #expect(results.map(\.kind) == ["repo", "issue", "mr"])
136 #expect(results[1].number == 168)
137 #expect(results[1].author == "cmc")
138 #expect(results[1].state == "closed")
139 }
140
141 /// A repository result carries no number, author, state or time —
142 /// they are omitempty and absent, not null.
143 @Test func aRepositoryResultHasNoNumberOrAuthor() throws {
144 let repo = try #require(try decode(rows).first)
145 #expect(repo.number == nil)
146 #expect(repo.author == nil)
147 #expect(repo.state == nil)
148 #expect(repo.updatedAt == nil)
149 }
150
151 /// A repo row and an issue row in the same repository must not
152 /// collide, or SwiftUI reuses one for the other.
153 @Test func idsAreUniqueAcrossKinds() throws {
154 let ids = try decode(rows).map(\.id)
155 #expect(Set(ids).count == ids.count)
156 }
157}
158
159struct SearchKindTests {
160
161 @Test func allSendsNoFlag() {
162 #expect(SearchKind.all.flag == [])
163 }
164
165 @Test func theOtherThreeSendTheirWireSpelling() {
166 #expect(SearchKind.repo.flag == ["--kind", "repo"])
167 #expect(SearchKind.issue.flag == ["--kind", "issue"])
168 #expect(SearchKind.mr.flag == ["--kind", "mr"])
169 }
170
171 @Test func everyKindTheCommandAcceptsIsOffered() {
172 #expect(SearchKind.allCases.map(\.rawValue) == ["all", "repo", "issue", "mr"])
173 }
174}
175
176@MainActor
177struct SearchViewModelTests {
178
179 @Test func anEmptyQuerySearchesNothing() async throws {
180 let (client, stub) = try makeClient()
181 let model = SearchViewModel(client: client)
182
183 model.query = " "
184 try await Task.sleep(for: .milliseconds(500))
185
186 #expect(stub.seen.isEmpty)
187 #expect(model.isSearching == false)
188 }
189
190 @Test func aQueryReachesTheSearchCommand() async throws {
191 let (client, stub) = try makeClient()
192 stub.enqueue(.init(status: 200, json: mixedResults))
193 let model = SearchViewModel(client: client)
194
195 model.query = "parity"
196 try await Task.sleep(for: .milliseconds(600))
197
198 let read = try #require(stub.seen.last)
199 #expect(argvFrom(read.url) == ["search", "parity"])
200 #expect(model.state.value?.count == 3)
201 #expect(model.isSearching)
202 }
203
204 @Test func typingDebouncesIntoOneRequest() async throws {
205 let (client, stub) = try makeClient()
206 stub.enqueue(.init(status: 200, json: mixedResults))
207 let model = SearchViewModel(client: client)
208
209 for text in ["p", "pa", "par", "pari", "parity"] { model.query = text }
210 try await Task.sleep(for: .milliseconds(700))
211
212 #expect(stub.seen.count == 1)
213 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity"])
214 }
215
216 @Test func narrowingByKindAddsTheFlag() async throws {
217 let (client, stub) = try makeClient()
218 stub.enqueue(.init(status: 200, json: mixedResults))
219 let model = SearchViewModel(client: client)
220 model.query = "parity"
221 try await Task.sleep(for: .milliseconds(600))
222
223 stub.enqueue(.init(status: 200, json: mixedResults))
224 model.kind = .issue
225 try await Task.sleep(for: .milliseconds(150))
226
227 #expect(argvFrom(try #require(stub.seen.last).url)
228 == ["search", "parity", "--kind", "issue"])
229 // A picker, not typing: two requests by now, not one.
230 #expect(stub.seen.count == 2)
231 }
232
233 /// Changing the kind with no query must not search.
234 @Test func kindAloneSearchesNothing() async throws {
235 let (client, stub) = try makeClient()
236 let model = SearchViewModel(client: client)
237
238 model.kind = .mr
239 try await Task.sleep(for: .milliseconds(400))
240
241 #expect(stub.seen.isEmpty)
242 }
243
244 /// The server quotes FTS5 terms itself, so an operator is a word.
245 @Test func theQueryIsPassedThroughUnquoted() async throws {
246 let (client, stub) = try makeClient()
247 stub.enqueue(.init(status: 200, json: mixedResults))
248 let model = SearchViewModel(client: client)
249
250 model.query = "c++"
251 try await Task.sleep(for: .milliseconds(600))
252
253 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "c++"])
254 }
255
256 @Test func clearingStopsSearchingAndDropsResults() async throws {
257 let (client, stub) = try makeClient()
258 stub.enqueue(.init(status: 200, json: mixedResults))
259 let model = SearchViewModel(client: client)
260 model.query = "parity"
261 try await Task.sleep(for: .milliseconds(600))
262 #expect(model.isSearching)
263
264 model.clear()
265
266 #expect(model.isSearching == false)
267 #expect(model.query.isEmpty)
268 }
269
270 @Test func nothingFoundIsAnEmptyStateNotAFailure() async throws {
271 let (client, stub) = try makeClient()
272 stub.enqueue(.init(status: 200, json: """
273 {"protocol_version":1,"data":[],"exit_code":0}
274 """))
275 let model = SearchViewModel(client: client)
276
277 model.query = "zzzznothing"
278 try await Task.sleep(for: .milliseconds(600))
279
280 guard case .empty = model.state else {
281 Testing.Issue.record("expected empty, got \(model.state)")
282 return
283 }
284 }
285}
286```
287
288**Note on decoding:** there is no shared test decoder in this codebase. `GitbayClient` builds a private one with a custom date strategy, and tests that decode a model directly make their own — see `FeedEventTests.decode` in `gitbayTests/DiscoveryTests.swift`, which is the pattern the block above copies. Do not invent a shared decoder.
289
290- [ ] **Step 2: Run to verify failure** — expect `cannot find type 'SearchResult' in scope`.
291
292- [ ] **Step 3: Implement**
293
294Create `gitbay/Discovery/SearchViewModel.swift`:
295
296```swift
297import Foundation
298import Observation
299
300/// One row of `search` — a repository, an issue or a merge request.
301/// Everything but `kind`, `repo` and `title` is omitempty on the wire: a
302/// repository result carries none of it.
303nonisolated struct SearchResult: Decodable, Sendable, Hashable, Identifiable {
304 let kind: String
305 let repo: String
306 let number: Int64?
307 let title: String
308 let author: String?
309 let state: String?
310 let updatedAt: Date?
311
312 enum CodingKeys: String, CodingKey {
313 case kind, repo, number, title, author, state
314 case updatedAt = "updated_at"
315 }
316
317 /// Unique across kinds: a repo row and an issue row in the same
318 /// repository would otherwise share an identity.
319 var id: String { "\(kind)/\(repo)/\(number.map(String.init) ?? "")" }
320
321 var isOpen: Bool { state == "open" }
322}
323
324/// What `search` is narrowed to. `all` is the absence of `--kind`.
325nonisolated enum SearchKind: String, CaseIterable, Identifiable, Sendable {
326 case all, repo, issue, mr
327
328 var id: String { rawValue }
329
330 var label: String {
331 switch self {
332 case .all: "All"
333 case .repo: "Repos"
334 case .issue: "Issues"
335 case .mr: "MRs"
336 }
337 }
338
339 var flag: [String] { self == .all ? [] : ["--kind", rawValue] }
340}
341
342/// `search <query> [--kind ...]` across everything the caller can read.
343/// Not paginated — the command takes no cursor.
344@Observable
345@MainActor
346final class SearchViewModel {
347
348 var query = "" {
349 didSet {
350 guard query != oldValue else { return }
351 scheduleSearch()
352 }
353 }
354
355 /// A picker, so it re-searches at once rather than debouncing.
356 var kind: SearchKind = .all {
357 didSet {
358 guard kind != oldValue, isSearching else { return }
359 searchTask?.cancel()
360 searchTask = Task { await run() }
361 }
362 }
363
364 private(set) var state: LoadState<[SearchResult]> = .loading
365
366 /// True once a non-blank query is present. The Explore listing shows
367 /// through whenever this is false.
368 var isSearching: Bool {
369 !query.trimmingCharacters(in: .whitespaces).isEmpty
370 }
371
372 private let client: GitbayClient
373 private var searchTask: Task<Void, Never>?
374
375 init(client: GitbayClient) {
376 self.client = client
377 }
378
379 func clear() {
380 searchTask?.cancel()
381 query = ""
382 }
383
384 private func scheduleSearch() {
385 searchTask?.cancel()
386 guard isSearching else { return }
387 state = .loading
388 searchTask = Task {
389 // Debounce a person typing, the way the repository list does.
390 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
391 guard !Task.isCancelled else { return }
392 await run()
393 }
394 }
395
396 private func run() async {
397 let text = query.trimmingCharacters(in: .whitespaces)
398 guard !text.isEmpty else { return }
399 do {
400 // Passed through unquoted: the server quotes FTS5 term by
401 // term, so an operator is a word to match, not a syntax error.
402 let results = try await client.readList(
403 ["search", text] + kind.flag, of: SearchResult.self
404 )
405 guard !Task.isCancelled else { return }
406 state = results.isEmpty ? .empty("Nothing matches “\(text)”.") : .loaded(results)
407 } catch {
408 guard !Task.isCancelled else { return }
409 state = .from(error)
410 }
411 }
412}
413```
414
415- [ ] **Step 4: Run the tests** — expect PASS and the full suite green.
416- [ ] **Step 5: Commit**
417
418```bash
419git add gitbay/Discovery/SearchViewModel.swift gitbayTests/SearchTests.swift
420git commit -m "Instance-wide search across repositories, issues and merge requests"
421```
422
423---
424
425### Task 2: Search on the Explore screen
426
427**Files:** Modify `gitbay/Views/Discovery/ExploreView.swift`
428
429No unit tests — UI. Verification is the build plus the suite staying green.
430
431`ExploreView` currently holds one `PagedListModel<PublicRepo>` over `explore`. Add a `SearchViewModel` beside it and switch on `search.isSearching`:
432
433- **Not searching:** the existing listing, unchanged. Do not disturb it.
434- **Searching:** a segmented `Picker` over `SearchKind.allCases` using `kind.label`, then the results, then `LoadStateOverlay(state: search.state)`.
435
436`.searchable(text: Bindable(search).query, prompt: "Search this instance")` on the `List`.
437
438A result row shows, by kind:
439- `repo` — the path, styled like the existing Explore rows (reuse the `path(of:)` helper's approach).
440- `issue` / `mr` — `repo` in secondary, `#number` or `!number` monospaced, the title, and the state as a `GBChip` (`.gbOK` when open, `.gbDone` for merged, `.gbBad` otherwise), plus the relative `updatedAt` if present.
441
442Routing by kind, using the existing route enums:
443- `repo` → `RepoRoute.repo(result.repo)`
444- `issue` → `IssueRoute.issue(repo: result.repo, number: n)`
445- `mr` → `MRRoute.mr(repo: result.repo, number: n)`
446
447A row whose kind needs a number but has none must not be tappable — render it as a plain row rather than force-unwrapping.
448
449- [ ] **Step 1: Add the search model and `.searchable`**
450- [ ] **Step 2: Add the kind picker and result rows, switched on `isSearching`**
451- [ ] **Step 3: Route each kind to its destination**
452- [ ] **Step 4: Build and run the full suite.** Expect 243+ passing, no drop.
453- [ ] **Step 5: Commit**
454
455```bash
456git commit -m "Search the instance from the Explore tab"
457```
458
459---
460
461### Task 3: Flip the parity row and open the merge request
462
463- [ ] **Step 1:** In the Discovery table, `search issues and merge requests` goes to `yes` for iOS. Touch no other row.
464
465Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff` — that repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work.
466
467- [ ] **Step 2:** Run the full suite; record the real number.
468- [ ] **Step 3:** Open the merge request.
469
470```bash
471gitbay mr create --source instance-search --target main \
472 --title "Search the instance from the Explore tab" --file - <<'EOF'
473`search` covers repositories plus the title and body of every issue and
474merge request the caller can read. The Explore tab keeps its listing when
475the field is empty and swaps in results when it is not, with a segmented
476control narrowing to one kind — `all` being the absence of `--kind`.
477
478The query is passed through unquoted, since the server quotes FTS5 term
479by term and an operator is a word to match. Typing debounces at 300ms;
480changing the kind re-searches at once.
481
482Closes the `search issues and merge requests` parity row.
483EOF
484```
485
486---
487
488## Notes for whoever executes this
489
490**`search` is not paginated.** It takes no `--limit` and no `--cursor`. Use `client.readList`. Reaching for `PagedListModel` here is wrong and will send flags the command rejects.
491
492**Four fields are optional and usually absent.** A repository result has no `number`, `author`, `state` or `updated_at`. Decoding must not require them, and the UI must not force-unwrap `number` to build a route.
493
494**Do not disturb the `explore` listing.** It is the screen's behaviour when nothing is typed, and it already works.
495
496**`all` sends no flag.** Sending `--kind all` would be a server error, since the command accepts only `repo`, `issue` and `mr`.
gitbay/Discovery/SearchViewModel.swift added +114
@@ -0,0 +1,114 @@
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 state = .loading
65 searchTask = Task { await run() }
66 }
67 }
68
69 private(set) var state: LoadState<[SearchResult]> = .loading
70
71 /// True once a non-blank query is present. The Explore listing shows
72 /// through whenever this is false.
73 var isSearching: Bool {
74 !query.trimmingCharacters(in: .whitespaces).isEmpty
75 }
76
77 private let client: GitbayClient
78 private var searchTask: Task<Void, Never>?
79
80 init(client: GitbayClient) {
81 self.client = client
82 }
83
84 private func scheduleSearch() {
85 searchTask?.cancel()
86 guard isSearching else { return }
87 // Keep prior results on screen while a refined search is in
88 // flight, the way the repository list does.
89 if state.value == nil { state = .loading }
90 searchTask = Task { [weak self] in
91 // Debounce a person typing, the way the repository list does.
92 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
93 guard !Task.isCancelled else { return }
94 await self?.run()
95 }
96 }
97
98 private func run() async {
99 let text = query.trimmingCharacters(in: .whitespaces)
100 guard !text.isEmpty else { return }
101 do {
102 // Passed through unquoted: the server quotes FTS5 term by
103 // term, so an operator is a word to match, not a syntax error.
104 let results = try await client.readList(
105 ["search", text] + kind.flag, of: SearchResult.self
106 )
107 guard !Task.isCancelled else { return }
108 state = results.isEmpty ? .empty("Nothing matches “\(text)”.") : .loaded(results)
109 } catch {
110 guard !Task.isCancelled else { return }
111 state = .from(error)
112 }
113 }
114}
gitbay/Views/Discovery/ExploreView.swift +137 −24
@@ -5,6 +5,7 @@ import SwiftUI
55struct ExploreView: View {
66
77 @State private var list: PagedListModel<PublicRepo>
8 @State private var search: SearchViewModel
89
910 init(client: GitbayClient) {
1011 _list = State(initialValue: PagedListModel(
@@ -12,6 +13,7 @@ struct ExploreView: View {
1213 argv: ["explore"],
1314 emptyMessage: "This instance hosts no public repositories."
1415 ))
16 _search = State(initialValue: SearchViewModel(client: client))
1517 }
1618
1719 /// One Text, not two: the HStack spaces its children, and a gap
@@ -24,46 +26,157 @@ struct ExploreView: View {
2426 return owner + name
2527 }
2628
29 /// Same styling as `path(of:)`, but for a search result's already
30 /// joined "owner/name" string.
31 private func styledPath(_ path: String) -> AttributedString {
32 guard let slash = path.firstIndex(of: "/") else {
33 return AttributedString(path)
34 }
35 var owner = AttributedString(String(path[...slash]))
36 owner.foregroundColor = .secondary
37 var name = AttributedString(String(path[path.index(after: slash)...]))
38 name.font = .gbSans(.subheadline).weight(.medium)
39 return owner + name
40 }
41
2742 var body: some View {
2843 List {
29 ForEach(list.state.value ?? []) { repo in
30 NavigationLink(value: RepoRoute.repo(repo.path)) {
31 VStack(alignment: .leading, spacing: 4) {
32 HStack(spacing: 6) {
33 Text(path(of: repo))
34 if repo.isArchived {
35 GBChip("archived", .secondary)
44 if search.isSearching {
45 Picker("Kind", selection: Bindable(search).kind) {
46 ForEach(SearchKind.allCases) { kind in
47 Text(kind.label).tag(kind)
48 }
49 }
50 .pickerStyle(.segmented)
51 .listRowBackground(Color.clear)
52 .listRowInsets(EdgeInsets())
53
54 ForEach(search.state.value ?? []) { result in
55 searchRow(for: result)
56 }
57 } else {
58 ForEach(list.state.value ?? []) { repo in
59 NavigationLink(value: RepoRoute.repo(repo.path)) {
60 VStack(alignment: .leading, spacing: 4) {
61 HStack(spacing: 6) {
62 Text(path(of: repo))
63 if repo.isArchived {
64 GBChip("archived", .secondary)
65 }
3666 }
37 }
38 .font(.gbSans(.subheadline))
39 .lineLimit(1)
67 .font(.gbSans(.subheadline))
68 .lineLimit(1)
4069
41 if let description = repo.description, !description.isEmpty {
42 Text(description)
43 .font(.gbSans(.caption))
44 .foregroundStyle(.secondary)
45 .lineLimit(2)
46 }
47 if let topics = repo.topics, !topics.isEmpty {
48 ScrollView(.horizontal, showsIndicators: false) {
49 HStack(spacing: 6) {
50 ForEach(topics, id: \.self) { topic in
51 GBChip(topic, .gbAccent)
70 if let description = repo.description, !description.isEmpty {
71 Text(description)
72 .font(.gbSans(.caption))
73 .foregroundStyle(.secondary)
74 .lineLimit(2)
75 }
76 if let topics = repo.topics, !topics.isEmpty {
77 ScrollView(.horizontal, showsIndicators: false) {
78 HStack(spacing: 6) {
79 ForEach(topics, id: \.self) { topic in
80 GBChip(topic, .gbAccent)
81 }
5282 }
5383 }
5484 }
5585 }
86 .padding(.vertical, 2)
5687 }
57 .padding(.vertical, 2)
5888 }
89 PageFooter(list: list)
5990 }
60 PageFooter(list: list)
6191 }
62 .overlay { LoadStateOverlay(state: list.state) }
92 .overlay {
93 if search.isSearching {
94 LoadStateOverlay(state: search.state)
95 } else {
96 LoadStateOverlay(state: list.state)
97 }
98 }
99 .searchable(text: Bindable(search).query, prompt: "Search this instance")
63100 .navigationTitle("Explore")
64101 .task {
65102 if list.state.value == nil { await list.reload() }
66103 }
67104 .refreshable { await list.reload() }
68105 }
106
107 @ViewBuilder
108 private func searchRow(for result: SearchResult) -> some View {
109 switch result.kind {
110 case "repo":
111 NavigationLink(value: RepoRoute.repo(result.repo)) {
112 Text(styledPath(result.repo))
113 .font(.gbSans(.subheadline))
114 .lineLimit(1)
115 .padding(.vertical, 2)
116 }
117 case "issue":
118 if let number = result.number {
119 NavigationLink(value: IssueRoute.issue(repo: result.repo, number: number)) {
120 SearchEntryRow(result: result)
121 }
122 } else {
123 SearchEntryRow(result: result)
124 }
125 case "mr":
126 if let number = result.number {
127 NavigationLink(value: MRRoute.mr(repo: result.repo, number: number)) {
128 SearchEntryRow(result: result)
129 }
130 } else {
131 SearchEntryRow(result: result)
132 }
133 default:
134 SearchEntryRow(result: result)
135 }
136 }
137}
138
139/// An issue or merge request row from `search` — `#` for an issue,
140/// `!` for a merge request, this forge's convention for the two.
141private struct SearchEntryRow: View {
142 let result: SearchResult
143
144 var body: some View {
145 VStack(alignment: .leading, spacing: 4) {
146 Text(result.repo)
147 .font(.gbSans(.caption))
148 .foregroundStyle(.secondary)
149 .lineLimit(1)
150 HStack(alignment: .firstTextBaseline, spacing: 6) {
151 if let number = result.number {
152 Text((result.kind == "issue" ? "#" : "!") + "\(number)")
153 .font(.gbMono(.caption))
154 .foregroundStyle(.secondary)
155 }
156 Text(result.title)
157 .font(.gbSans(.subheadline).weight(.medium))
158 .lineLimit(2)
159 }
160 HStack(spacing: 6) {
161 if let state = result.state {
162 GBChip(state, color(for: state))
163 }
164 Spacer()
165 if let updatedAt = result.updatedAt {
166 Text(updatedAt, format: .relative(presentation: .named))
167 .font(.gbSans(.caption))
168 .foregroundStyle(.secondary)
169 }
170 }
171 }
172 .padding(.vertical, 2)
173 }
174
175 private func color(for state: String) -> Color {
176 switch state {
177 case "open": .gbOK
178 case "merged": .gbDone
179 default: .gbBad
180 }
181 }
69182}
gitbayTests/SearchTests.swift added +213
@@ -0,0 +1,213 @@
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 nothingFoundIsAnEmptyStateNotAFailure() async throws {
172 let (client, stub) = try makeClient()
173 stub.enqueue(.init(status: 200, json: """
174 {"protocol_version":1,"data":[],"exit_code":0}
175 """))
176 let model = SearchViewModel(client: client)
177
178 model.query = "zzzznothing"
179 try await Task.sleep(for: .milliseconds(600))
180
181 guard case .empty = model.state else {
182 Testing.Issue.record("expected empty, got \(model.state)")
183 return
184 }
185 }
186
187 @Test func aServerFailureLandsInFailedNotEmpty() async throws {
188 let (client, stub) = try makeClient()
189 stub.enqueue(.init(status: 200, json: """
190 {"protocol_version":1,"error":"boom","exit_code":1}
191 """))
192 let model = SearchViewModel(client: client)
193
194 model.query = "parity"
195 try await Task.sleep(for: .milliseconds(600))
196
197 guard case .failed = model.state else {
198 Testing.Issue.record("expected failed, got \(model.state)")
199 return
200 }
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 }
213}