# MR 3: Instance-wide search Implementation Plan > **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. **Goal:** Close the `search issues and merge requests` parity row — one query across repositories, issues and merge requests, from the Explore tab. **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. **Tech Stack:** Swift 6, default `MainActor` isolation, iOS 26.5, SwiftUI, Swift Testing, `@Observable`. **Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md` ## Global Constraints - Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s. - Swift Testing only — never XCTest. - `gitbayTests` is hermetic and offline; network goes through `StubProtocol`. - New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit. - The label model is `IssueLabel`, never `Label`. - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer. - Never commit to `main`. **The command, verbatim from the registry:** ``` search [--kind repo|issue|mr] ``` Two 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. **The result shape**, from `internal/control/search.go`: ```json {"kind":"issue","repo":"krz/gitbay","number":168,"title":"...","author":"cmc", "state":"closed","updated_at":"2026-09-06T16:53:52.595Z"} ``` `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. **Build and test command:** ```bash xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'platform=iOS Simulator,name=iPhone 17' \ test -only-testing:gitbayTests ``` Baseline: **243 passed, 0 failed, 1 skipped** (`LiveInstanceTests` — expected). --- ## File Structure | File | Responsibility | |------|----------------| | `gitbay/Discovery/SearchViewModel.swift` (create) | `SearchResult` wire model, `SearchKind`, and the query/debounce view model | | `gitbayTests/SearchTests.swift` (create) | Every test in this plan | | `gitbay/Views/Discovery/ExploreView.swift` (modify) | `.searchable`, kind picker, result rows, routing | --- ### Task 1: The search model and view model **Files:** - Create: `gitbay/Discovery/SearchViewModel.swift` - Test: `gitbayTests/SearchTests.swift` **Interfaces produced:** - `nonisolated struct SearchResult: Decodable, Sendable, Hashable, Identifiable` — `kind: String`, `repo: String`, `number: Int64?`, `title: String`, `author: String?`, `state: String?`, `updatedAt: Date?`; `var id: String` - `nonisolated enum SearchKind: String, CaseIterable, Identifiable, Sendable` — `all`, `repo`, `issue`, `mr`; `var label: String`; `var flag: [String]` - `@Observable @MainActor final class SearchViewModel` — `query: String`, `kind: SearchKind`, `state: LoadState<[SearchResult]>`, `var isSearching: Bool`, `func clear()` Behaviour, each pinned by a test: 1. 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. 2. Typing debounces at 300ms, matching `RepoListViewModel` and the list filters — one request per pause, not per keystroke. 3. `kind == .all` sends **no `--kind` flag**; the other three send `--kind repo|issue|mr` with the wire spelling. 4. Changing the kind while a query is present re-searches **immediately** — it is a picker, not typing. 5. The query is passed through **unquoted**. The server quotes FTS5 term by term, so `c++` is a word to match. 6. `id` is unique across kinds and repos — a repo row and an issue row in the same repository must not collide. - [ ] **Step 1: Write the failing tests** Create `gitbayTests/SearchTests.swift`: ```swift import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func argvFrom(_ url: URL) -> [String] { URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] } private let mixedResults = """ {"protocol_version":1,"data":[\ {"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\ {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\ "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52.595Z"},\ {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\ "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13.062Z"}\ ],"exit_code":0} """ struct SearchResultDecodingTests { /// Matches the established direct-decode pattern in /// `DiscoveryTests.swift`: a plain decoder with `.iso8601` dates. private func decode(_ json: String) throws -> [SearchResult] { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode([SearchResult].self, from: Data(json.utf8)) } private let rows = """ [{"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\ {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\ "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52Z"},\ {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\ "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13Z"}] """ @Test func decodesAllThreeKinds() throws { let results = try decode(rows) #expect(results.map(\.kind) == ["repo", "issue", "mr"]) #expect(results[1].number == 168) #expect(results[1].author == "cmc") #expect(results[1].state == "closed") } /// A repository result carries no number, author, state or time — /// they are omitempty and absent, not null. @Test func aRepositoryResultHasNoNumberOrAuthor() throws { let repo = try #require(try decode(rows).first) #expect(repo.number == nil) #expect(repo.author == nil) #expect(repo.state == nil) #expect(repo.updatedAt == nil) } /// A repo row and an issue row in the same repository must not /// collide, or SwiftUI reuses one for the other. @Test func idsAreUniqueAcrossKinds() throws { let ids = try decode(rows).map(\.id) #expect(Set(ids).count == ids.count) } } struct SearchKindTests { @Test func allSendsNoFlag() { #expect(SearchKind.all.flag == []) } @Test func theOtherThreeSendTheirWireSpelling() { #expect(SearchKind.repo.flag == ["--kind", "repo"]) #expect(SearchKind.issue.flag == ["--kind", "issue"]) #expect(SearchKind.mr.flag == ["--kind", "mr"]) } @Test func everyKindTheCommandAcceptsIsOffered() { #expect(SearchKind.allCases.map(\.rawValue) == ["all", "repo", "issue", "mr"]) } } @MainActor struct SearchViewModelTests { @Test func anEmptyQuerySearchesNothing() async throws { let (client, stub) = try makeClient() let model = SearchViewModel(client: client) model.query = " " try await Task.sleep(for: .milliseconds(500)) #expect(stub.seen.isEmpty) #expect(model.isSearching == false) } @Test func aQueryReachesTheSearchCommand() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mixedResults)) let model = SearchViewModel(client: client) model.query = "parity" try await Task.sleep(for: .milliseconds(600)) let read = try #require(stub.seen.last) #expect(argvFrom(read.url) == ["search", "parity"]) #expect(model.state.value?.count == 3) #expect(model.isSearching) } @Test func typingDebouncesIntoOneRequest() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mixedResults)) let model = SearchViewModel(client: client) for text in ["p", "pa", "par", "pari", "parity"] { model.query = text } try await Task.sleep(for: .milliseconds(700)) #expect(stub.seen.count == 1) #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity"]) } @Test func narrowingByKindAddsTheFlag() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mixedResults)) let model = SearchViewModel(client: client) model.query = "parity" try await Task.sleep(for: .milliseconds(600)) stub.enqueue(.init(status: 200, json: mixedResults)) model.kind = .issue try await Task.sleep(for: .milliseconds(150)) #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity", "--kind", "issue"]) // A picker, not typing: two requests by now, not one. #expect(stub.seen.count == 2) } /// Changing the kind with no query must not search. @Test func kindAloneSearchesNothing() async throws { let (client, stub) = try makeClient() let model = SearchViewModel(client: client) model.kind = .mr try await Task.sleep(for: .milliseconds(400)) #expect(stub.seen.isEmpty) } /// The server quotes FTS5 terms itself, so an operator is a word. @Test func theQueryIsPassedThroughUnquoted() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mixedResults)) let model = SearchViewModel(client: client) model.query = "c++" try await Task.sleep(for: .milliseconds(600)) #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "c++"]) } @Test func clearingStopsSearchingAndDropsResults() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mixedResults)) let model = SearchViewModel(client: client) model.query = "parity" try await Task.sleep(for: .milliseconds(600)) #expect(model.isSearching) model.clear() #expect(model.isSearching == false) #expect(model.query.isEmpty) } @Test func nothingFoundIsAnEmptyStateNotAFailure() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":[],"exit_code":0} """)) let model = SearchViewModel(client: client) model.query = "zzzznothing" try await Task.sleep(for: .milliseconds(600)) guard case .empty = model.state else { Testing.Issue.record("expected empty, got \(model.state)") return } } } ``` **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. - [ ] **Step 2: Run to verify failure** — expect `cannot find type 'SearchResult' in scope`. - [ ] **Step 3: Implement** Create `gitbay/Discovery/SearchViewModel.swift`: ```swift import Foundation import Observation /// One row of `search` — a repository, an issue or a merge request. /// Everything but `kind`, `repo` and `title` is omitempty on the wire: a /// repository result carries none of it. nonisolated struct SearchResult: Decodable, Sendable, Hashable, Identifiable { let kind: String let repo: String let number: Int64? let title: String let author: String? let state: String? let updatedAt: Date? enum CodingKeys: String, CodingKey { case kind, repo, number, title, author, state case updatedAt = "updated_at" } /// Unique across kinds: a repo row and an issue row in the same /// repository would otherwise share an identity. var id: String { "\(kind)/\(repo)/\(number.map(String.init) ?? "")" } var isOpen: Bool { state == "open" } } /// What `search` is narrowed to. `all` is the absence of `--kind`. nonisolated enum SearchKind: String, CaseIterable, Identifiable, Sendable { case all, repo, issue, mr var id: String { rawValue } var label: String { switch self { case .all: "All" case .repo: "Repos" case .issue: "Issues" case .mr: "MRs" } } var flag: [String] { self == .all ? [] : ["--kind", rawValue] } } /// `search [--kind ...]` across everything the caller can read. /// Not paginated — the command takes no cursor. @Observable @MainActor final class SearchViewModel { var query = "" { didSet { guard query != oldValue else { return } scheduleSearch() } } /// A picker, so it re-searches at once rather than debouncing. var kind: SearchKind = .all { didSet { guard kind != oldValue, isSearching else { return } searchTask?.cancel() searchTask = Task { await run() } } } private(set) var state: LoadState<[SearchResult]> = .loading /// True once a non-blank query is present. The Explore listing shows /// through whenever this is false. var isSearching: Bool { !query.trimmingCharacters(in: .whitespaces).isEmpty } private let client: GitbayClient private var searchTask: Task? init(client: GitbayClient) { self.client = client } func clear() { searchTask?.cancel() query = "" } private func scheduleSearch() { searchTask?.cancel() guard isSearching else { return } state = .loading searchTask = Task { // Debounce a person typing, the way the repository list does. guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return } guard !Task.isCancelled else { return } await run() } } private func run() async { let text = query.trimmingCharacters(in: .whitespaces) guard !text.isEmpty else { return } do { // Passed through unquoted: the server quotes FTS5 term by // term, so an operator is a word to match, not a syntax error. let results = try await client.readList( ["search", text] + kind.flag, of: SearchResult.self ) guard !Task.isCancelled else { return } state = results.isEmpty ? .empty("Nothing matches “\(text)”.") : .loaded(results) } catch { guard !Task.isCancelled else { return } state = .from(error) } } } ``` - [ ] **Step 4: Run the tests** — expect PASS and the full suite green. - [ ] **Step 5: Commit** ```bash git add gitbay/Discovery/SearchViewModel.swift gitbayTests/SearchTests.swift git commit -m "Instance-wide search across repositories, issues and merge requests" ``` --- ### Task 2: Search on the Explore screen **Files:** Modify `gitbay/Views/Discovery/ExploreView.swift` No unit tests — UI. Verification is the build plus the suite staying green. `ExploreView` currently holds one `PagedListModel` over `explore`. Add a `SearchViewModel` beside it and switch on `search.isSearching`: - **Not searching:** the existing listing, unchanged. Do not disturb it. - **Searching:** a segmented `Picker` over `SearchKind.allCases` using `kind.label`, then the results, then `LoadStateOverlay(state: search.state)`. `.searchable(text: Bindable(search).query, prompt: "Search this instance")` on the `List`. A result row shows, by kind: - `repo` — the path, styled like the existing Explore rows (reuse the `path(of:)` helper's approach). - `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. Routing by kind, using the existing route enums: - `repo` → `RepoRoute.repo(result.repo)` - `issue` → `IssueRoute.issue(repo: result.repo, number: n)` - `mr` → `MRRoute.mr(repo: result.repo, number: n)` A row whose kind needs a number but has none must not be tappable — render it as a plain row rather than force-unwrapping. - [ ] **Step 1: Add the search model and `.searchable`** - [ ] **Step 2: Add the kind picker and result rows, switched on `isSearching`** - [ ] **Step 3: Route each kind to its destination** - [ ] **Step 4: Build and run the full suite.** Expect 243+ passing, no drop. - [ ] **Step 5: Commit** ```bash git commit -m "Search the instance from the Explore tab" ``` --- ### Task 3: Flip the parity row and open the merge request - [ ] **Step 1:** In the Discovery table, `search issues and merge requests` goes to `yes` for iOS. Touch no other row. Land 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. - [ ] **Step 2:** Run the full suite; record the real number. - [ ] **Step 3:** Open the merge request. ```bash gitbay mr create --source instance-search --target main \ --title "Search the instance from the Explore tab" --file - <<'EOF' `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`. 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; changing the kind re-searches at once. Closes the `search issues and merge requests` parity row. EOF ``` --- ## Notes for whoever executes this **`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. **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. **Do not disturb the `explore` listing.** It is the screen's behaviour when nothing is typed, and it already works. **`all` sends no flag.** Sending `--kind all` would be a server error, since the command accepts only `repo`, `issue` and `mr`.