Commit bfdfd9eda7
Verified · cmc
Layout: unified · split
docs/superpowers/plans/2026-09-06-mr02-list-filters.md added +667
| @@ -0,0 +1,667 @@ | |||
| 1 | # MR 2: List filters and 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 three parity rows — `issue: filter by label, assignee, author, milestone`, `issue: search title and body`, and `mr: search title and body`. | ||
| 6 | |||
| 7 | **Architecture:** Two value types, `IssueFilter` and `MRFilter`, each rendering itself to the flags its list command accepts. Both list view models swap their bare `StateFilter` enum for one of these and rebuild `PagedListModel.argv` from it. Typing debounces (300ms, matching `RepoListViewModel`); picker changes reload immediately. The label picker reads `IssueListViewModel.labels`, which MR 1 already loads. | ||
| 8 | |||
| 9 | **Tech Stack:** Swift 6 language mode, default `MainActor` isolation, iOS 26.5, SwiftUI, Swift Testing, `@Observable` view models. | ||
| 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 (`import Testing`, `@Test`, `#expect`, `#require`) — never XCTest. | ||
| 17 | - `gitbayTests` is hermetic and offline; network goes through `StubProtocol`. | ||
| 18 | - New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit — both are `PBXFileSystemSynchronizedRootGroup`s. | ||
| 19 | - The label model is `IssueLabel`, never `Label` — SwiftUI's `Label` is used 57 times in this module. | ||
| 20 | - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer. | ||
| 21 | - Never commit to `main`. Work on a branch, land as a merge request. | ||
| 22 | |||
| 23 | **The commands, verbatim from the registry** — these are the only flags that exist, and sending one that does not is a server error the app cannot catch: | ||
| 24 | |||
| 25 | ``` | ||
| 26 | issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] | ||
| 27 | [--author <user>] [--milestone <title>|none] [--search <text>] | ||
| 28 | [--limit <n>] [--cursor <c>] | ||
| 29 | |||
| 30 | mr list <owner/name> [--state open|merged|closed|source_gone|all] [--author <user>] | ||
| 31 | [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>] | ||
| 32 | ``` | ||
| 33 | |||
| 34 | `mr list` takes **no** `--label` and **no** `--assignee`. `MRFilter` must not offer them. | ||
| 35 | |||
| 36 | **Build and test command:** | ||
| 37 | |||
| 38 | ```bash | ||
| 39 | xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 40 | -destination 'platform=iOS Simulator,name=iPhone 17' \ | ||
| 41 | test -only-testing:gitbayTests | ||
| 42 | ``` | ||
| 43 | |||
| 44 | Baseline before this plan: **217 passed, 0 failed, 1 skipped** (`LiveInstanceTests`, conditionally enabled on a token — expected). | ||
| 45 | |||
| 46 | --- | ||
| 47 | |||
| 48 | ## File Structure | ||
| 49 | |||
| 50 | | File | Responsibility | | ||
| 51 | |------|----------------| | ||
| 52 | | `gitbay/Issues/IssueFilter.swift` (create) | `IssueFilter` — state, search, label, assignee, author, milestone → flags | | ||
| 53 | | `gitbay/MRs/MRFilter.swift` (create) | `MRFilter` — state, search, author, milestone → flags | | ||
| 54 | | `gitbayTests/ListFilterTests.swift` (create) | Every test in this plan | | ||
| 55 | | `gitbay/Issues/IssueListViewModel.swift` (modify) | Hold `IssueFilter`, debounce search, rebuild argv | | ||
| 56 | | `gitbay/MRs/MRListViewModel.swift` (modify) | Hold `MRFilter`, debounce search, rebuild argv | | ||
| 57 | | `gitbay/Views/Issues/IssueListView.swift` (modify) | `.searchable` + filter sheet | | ||
| 58 | | `gitbay/Views/MRs/MRListView.swift` (modify) | `.searchable` + filter sheet | | ||
| 59 | | `gitbay/Views/Shared/FilterSheet.swift` (create) | The filter form, shared by both lists | | ||
| 60 | |||
| 61 | --- | ||
| 62 | |||
| 63 | ### Task 1: The two filter value types | ||
| 64 | |||
| 65 | Pure logic, no network, no UI. The whole task is argv construction, which is where the risk lives. | ||
| 66 | |||
| 67 | **Files:** | ||
| 68 | - Create: `gitbay/Issues/IssueFilter.swift`, `gitbay/MRs/MRFilter.swift` | ||
| 69 | - Test: `gitbayTests/ListFilterTests.swift` | ||
| 70 | |||
| 71 | **Interfaces produced:** | ||
| 72 | - `struct IssueFilter: Equatable, Sendable` — `state: State` (`.open`/`.closed`/`.all`), `search: String`, `label: String?`, `assignee: String?`, `author: String?`, `milestone: String?`; `func flags() -> [String]`; `var isNarrowed: Bool`; `var emptyMessage: String` | ||
| 73 | - `struct MRFilter: Equatable, Sendable` — `state: State` (`.open`/`.merged`/`.closed`/`.sourceGone`/`.all`), `search: String`, `author: String?`, `milestone: String?`; same three members | ||
| 74 | - Both nest `enum State: String, CaseIterable, Identifiable, Sendable` with a `label: String` for display | ||
| 75 | |||
| 76 | Rules the flags must follow, each pinned by a test: | ||
| 77 | 1. `--state` is always sent, first. | ||
| 78 | 2. An empty or whitespace-only value sends **no flag at all**. `--search ""` is not the same as no search, and a flag with an empty value is a different query. | ||
| 79 | 3. `nil` and `""` both mean absent for the optional fields. | ||
| 80 | 4. `milestone` is the one exception where a literal value matters: `"none"` is a real filter meaning "no milestone", not an absence. | ||
| 81 | 5. Values are passed through unquoted. The server quotes FTS5 term by term, so `c++` is a word to match, not a syntax error — the app must not escape it. | ||
| 82 | |||
| 83 | - [ ] **Step 1: Write the failing tests** | ||
| 84 | |||
| 85 | Create `gitbayTests/ListFilterTests.swift`: | ||
| 86 | |||
| 87 | ```swift | ||
| 88 | import Foundation | ||
| 89 | import Testing | ||
| 90 | @testable import gitbay | ||
| 91 | |||
| 92 | struct IssueFilterTests { | ||
| 93 | |||
| 94 | @Test func theDefaultIsOpenAndNothingElse() { | ||
| 95 | #expect(IssueFilter().flags() == ["--state", "open"]) | ||
| 96 | #expect(IssueFilter().isNarrowed == false) | ||
| 97 | } | ||
| 98 | |||
| 99 | @Test func stateIsAlwaysFirst() { | ||
| 100 | var f = IssueFilter() | ||
| 101 | f.state = .closed | ||
| 102 | f.author = "cmc" | ||
| 103 | #expect(f.flags().prefix(2) == ["--state", "closed"]) | ||
| 104 | } | ||
| 105 | |||
| 106 | @Test func eachFieldRendersItsOwnFlag() { | ||
| 107 | var f = IssueFilter() | ||
| 108 | f.label = "bug" | ||
| 109 | f.assignee = "cmc" | ||
| 110 | f.author = "rae" | ||
| 111 | f.milestone = "v1.0.0" | ||
| 112 | f.search = "crash on launch" | ||
| 113 | #expect(f.flags() == [ | ||
| 114 | "--state", "open", | ||
| 115 | "--search", "crash on launch", | ||
| 116 | "--label", "bug", | ||
| 117 | "--assignee", "cmc", | ||
| 118 | "--author", "rae", | ||
| 119 | "--milestone", "v1.0.0", | ||
| 120 | ]) | ||
| 121 | #expect(f.isNarrowed) | ||
| 122 | } | ||
| 123 | |||
| 124 | /// An empty value must send no flag. `--search ""` is a different | ||
| 125 | /// query from no search at all. | ||
| 126 | @Test func emptyAndWhitespaceValuesSendNoFlag() { | ||
| 127 | var f = IssueFilter() | ||
| 128 | f.search = " " | ||
| 129 | f.label = "" | ||
| 130 | f.assignee = nil | ||
| 131 | f.author = " " | ||
| 132 | f.milestone = "" | ||
| 133 | #expect(f.flags() == ["--state", "open"]) | ||
| 134 | #expect(f.isNarrowed == false) | ||
| 135 | } | ||
| 136 | |||
| 137 | /// "none" is a real milestone filter meaning "has no milestone". | ||
| 138 | @Test func milestoneNoneIsAValueNotAnAbsence() { | ||
| 139 | var f = IssueFilter() | ||
| 140 | f.milestone = "none" | ||
| 141 | #expect(f.flags() == ["--state", "open", "--milestone", "none"]) | ||
| 142 | #expect(f.isNarrowed) | ||
| 143 | } | ||
| 144 | |||
| 145 | /// The server quotes FTS5 terms itself, so an operator is a word to | ||
| 146 | /// match. The app must not escape or reject it. | ||
| 147 | @Test func searchTextIsPassedThroughUnquoted() { | ||
| 148 | var f = IssueFilter() | ||
| 149 | f.search = "c++ AND \"quoted" | ||
| 150 | #expect(f.flags() == ["--state", "open", "--search", "c++ AND \"quoted"]) | ||
| 151 | } | ||
| 152 | |||
| 153 | @Test func surroundingWhitespaceIsTrimmedFromValues() { | ||
| 154 | var f = IssueFilter() | ||
| 155 | f.search = " crash " | ||
| 156 | f.label = " bug " | ||
| 157 | #expect(f.flags() == ["--state", "open", "--search", "crash", "--label", "bug"]) | ||
| 158 | } | ||
| 159 | |||
| 160 | @Test func theEmptyMessageNamesTheNarrowing() { | ||
| 161 | #expect(IssueFilter().emptyMessage == "No open issues.") | ||
| 162 | var all = IssueFilter() | ||
| 163 | all.state = .all | ||
| 164 | #expect(all.emptyMessage == "No issues.") | ||
| 165 | var narrowed = IssueFilter() | ||
| 166 | narrowed.label = "bug" | ||
| 167 | #expect(narrowed.emptyMessage == "No open issues match these filters.") | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | struct MRFilterTests { | ||
| 172 | |||
| 173 | @Test func theDefaultIsOpenAndNothingElse() { | ||
| 174 | #expect(MRFilter().flags() == ["--state", "open"]) | ||
| 175 | #expect(MRFilter().isNarrowed == false) | ||
| 176 | } | ||
| 177 | |||
| 178 | @Test func eachFieldRendersItsOwnFlag() { | ||
| 179 | var f = MRFilter() | ||
| 180 | f.author = "cmc" | ||
| 181 | f.milestone = "v1.0.0" | ||
| 182 | f.search = "retarget" | ||
| 183 | #expect(f.flags() == [ | ||
| 184 | "--state", "open", | ||
| 185 | "--search", "retarget", | ||
| 186 | "--author", "cmc", | ||
| 187 | "--milestone", "v1.0.0", | ||
| 188 | ]) | ||
| 189 | } | ||
| 190 | |||
| 191 | /// `mr list` accepts source_gone; the spelling is the wire spelling, | ||
| 192 | /// not the Swift case name. | ||
| 193 | @Test func sourceGoneSpellsItselfTheWayTheCommandDoes() { | ||
| 194 | var f = MRFilter() | ||
| 195 | f.state = .sourceGone | ||
| 196 | #expect(f.flags() == ["--state", "source_gone"]) | ||
| 197 | #expect(MRFilter.State.sourceGone.label == "Source gone") | ||
| 198 | } | ||
| 199 | |||
| 200 | @Test func everyStateTheCommandAcceptsIsOffered() { | ||
| 201 | #expect(MRFilter.State.allCases.map(\.rawValue) | ||
| 202 | == ["open", "merged", "closed", "source_gone", "all"]) | ||
| 203 | } | ||
| 204 | |||
| 205 | @Test func emptyValuesSendNoFlag() { | ||
| 206 | var f = MRFilter() | ||
| 207 | f.search = " " | ||
| 208 | f.author = "" | ||
| 209 | #expect(f.flags() == ["--state", "open"]) | ||
| 210 | } | ||
| 211 | |||
| 212 | @Test func theEmptyMessageNamesTheNarrowing() { | ||
| 213 | #expect(MRFilter().emptyMessage == "No open merge requests.") | ||
| 214 | var narrowed = MRFilter() | ||
| 215 | narrowed.search = "x" | ||
| 216 | #expect(narrowed.emptyMessage == "No open merge requests match these filters.") | ||
| 217 | } | ||
| 218 | } | ||
| 219 | ``` | ||
| 220 | |||
| 221 | - [ ] **Step 2: Run the tests to verify they fail** | ||
| 222 | |||
| 223 | Run the suite filtered to `-only-testing:gitbayTests/IssueFilterTests`. | ||
| 224 | Expected: build failure — `cannot find type 'IssueFilter' in scope`. | ||
| 225 | |||
| 226 | - [ ] **Step 3: Write the implementation** | ||
| 227 | |||
| 228 | Create `gitbay/Issues/IssueFilter.swift`: | ||
| 229 | |||
| 230 | ```swift | ||
| 231 | import Foundation | ||
| 232 | |||
| 233 | /// What `issue list` is narrowed to. Renders itself to the flags the | ||
| 234 | /// command accepts; an empty value sends no flag at all, since | ||
| 235 | /// `--search ""` is a different query from no search. | ||
| 236 | nonisolated struct IssueFilter: Equatable, Sendable { | ||
| 237 | |||
| 238 | enum State: String, CaseIterable, Identifiable, Sendable { | ||
| 239 | case open, closed, all | ||
| 240 | var id: String { rawValue } | ||
| 241 | var label: String { rawValue.capitalized } | ||
| 242 | } | ||
| 243 | |||
| 244 | var state: State = .open | ||
| 245 | var search = "" | ||
| 246 | var label: String? | ||
| 247 | var assignee: String? | ||
| 248 | var author: String? | ||
| 249 | /// A milestone title, or the literal "none" for issues without one. | ||
| 250 | var milestone: String? | ||
| 251 | |||
| 252 | func flags() -> [String] { | ||
| 253 | var flags = ["--state", state.rawValue] | ||
| 254 | flags += filterFlag("--search", search) | ||
| 255 | flags += filterFlag("--label", label) | ||
| 256 | flags += filterFlag("--assignee", assignee) | ||
| 257 | flags += filterFlag("--author", author) | ||
| 258 | flags += filterFlag("--milestone", milestone) | ||
| 259 | return flags | ||
| 260 | } | ||
| 261 | |||
| 262 | /// True when anything beyond the state picker is set, which is what | ||
| 263 | /// decides whether the empty state blames the filters. | ||
| 264 | var isNarrowed: Bool { flags().count > 2 } | ||
| 265 | |||
| 266 | var emptyMessage: String { | ||
| 267 | if isNarrowed { | ||
| 268 | return state == .all | ||
| 269 | ? "No issues match these filters." | ||
| 270 | : "No \(state.rawValue) issues match these filters." | ||
| 271 | } | ||
| 272 | return state == .all ? "No issues." : "No \(state.rawValue) issues." | ||
| 273 | } | ||
| 274 | } | ||
| 275 | |||
| 276 | /// One flag, or nothing when the value is absent or blank. Shared by | ||
| 277 | /// both filters, so it is named for what it does rather than taking the | ||
| 278 | /// bare name `flag` in the module namespace. | ||
| 279 | nonisolated func filterFlag(_ name: String, _ value: String?) -> [String] { | ||
| 280 | guard let value else { return [] } | ||
| 281 | let trimmed = value.trimmingCharacters(in: .whitespaces) | ||
| 282 | return trimmed.isEmpty ? [] : [name, trimmed] | ||
| 283 | } | ||
| 284 | ``` | ||
| 285 | |||
| 286 | Create `gitbay/MRs/MRFilter.swift`: | ||
| 287 | |||
| 288 | ```swift | ||
| 289 | import Foundation | ||
| 290 | |||
| 291 | /// What `mr list` is narrowed to. The command takes no `--label` and no | ||
| 292 | /// `--assignee`, so this offers neither. | ||
| 293 | nonisolated struct MRFilter: Equatable, Sendable { | ||
| 294 | |||
| 295 | enum State: String, CaseIterable, Identifiable, Sendable { | ||
| 296 | case open, merged, closed | ||
| 297 | case sourceGone = "source_gone" | ||
| 298 | case all | ||
| 299 | |||
| 300 | var id: String { rawValue } | ||
| 301 | var label: String { | ||
| 302 | self == .sourceGone ? "Source gone" : rawValue.capitalized | ||
| 303 | } | ||
| 304 | } | ||
| 305 | |||
| 306 | var state: State = .open | ||
| 307 | var search = "" | ||
| 308 | var author: String? | ||
| 309 | var milestone: String? | ||
| 310 | |||
| 311 | func flags() -> [String] { | ||
| 312 | var flags = ["--state", state.rawValue] | ||
| 313 | flags += filterFlag("--search", search) | ||
| 314 | flags += filterFlag("--author", author) | ||
| 315 | flags += filterFlag("--milestone", milestone) | ||
| 316 | return flags | ||
| 317 | } | ||
| 318 | |||
| 319 | var isNarrowed: Bool { flags().count > 2 } | ||
| 320 | |||
| 321 | var emptyMessage: String { | ||
| 322 | let noun = "merge requests" | ||
| 323 | if isNarrowed { | ||
| 324 | return state == .all | ||
| 325 | ? "No \(noun) match these filters." | ||
| 326 | : "No \(state.label.lowercased()) \(noun) match these filters." | ||
| 327 | } | ||
| 328 | return state == .all ? "No \(noun)." : "No \(state.label.lowercased()) \(noun)." | ||
| 329 | } | ||
| 330 | } | ||
| 331 | ``` | ||
| 332 | |||
| 333 | - [ ] **Step 4: Run the tests to verify they pass** | ||
| 334 | |||
| 335 | Expected: PASS, and the full suite still green. | ||
| 336 | |||
| 337 | - [ ] **Step 5: Commit** | ||
| 338 | |||
| 339 | ```bash | ||
| 340 | git add gitbay/Issues/IssueFilter.swift gitbay/MRs/MRFilter.swift gitbayTests/ListFilterTests.swift | ||
| 341 | git commit -m "Filter value types for the issue and merge request lists" | ||
| 342 | ``` | ||
| 343 | |||
| 344 | --- | ||
| 345 | |||
| 346 | ### Task 2: Wire the filters into both view models | ||
| 347 | |||
| 348 | **Files:** | ||
| 349 | - Modify: `gitbay/Issues/IssueListViewModel.swift`, `gitbay/MRs/MRListViewModel.swift` | ||
| 350 | - Test: `gitbayTests/ListFilterTests.swift` | ||
| 351 | |||
| 352 | **Interfaces produced:** | ||
| 353 | - `IssueListViewModel.filter: IssueFilter` (replacing `filter: StateFilter`), `IssueListViewModel.milestones: [Milestone]`, `func loadMilestones() async` | ||
| 354 | - `MRListViewModel.filter: MRFilter` (replacing `filter: StateFilter`), same two milestone members | ||
| 355 | |||
| 356 | `StateFilter` is **removed** from both view models — `IssueFilter.State` and `MRFilter.State` replace it. Update the views' pickers in Task 3 accordingly. | ||
| 357 | |||
| 358 | Two reload paths, and the difference matters: | ||
| 359 | - Changing a picker reloads **immediately**. | ||
| 360 | - Changing `filter.search` reloads **after a 300ms debounce**, matching `RepoListViewModel.scheduleSearch`. Reloading on every keystroke would fire a request per character. | ||
| 361 | |||
| 362 | Because both live on one `filter` property, the view model compares old and new in `didSet`: if only `search` differs, debounce; otherwise reload now. | ||
| 363 | |||
| 364 | Milestones are fetched lazily for the picker — `IssueDetailViewModel` already does this with `availableMilestones`; follow that shape. Use `["milestone", "list", repoPath, "--state", "all"]` so a closed milestone can still be filtered on. | ||
| 365 | |||
| 366 | - [ ] **Step 1: Write the failing tests** | ||
| 367 | |||
| 368 | Append to `gitbayTests/ListFilterTests.swift`. Reuse the file-scope helpers by declaring them here (this file is new, so it needs its own copies): | ||
| 369 | |||
| 370 | ```swift | ||
| 371 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 372 | let box = StubProtocol.box() | ||
| 373 | let client = GitbayClient( | ||
| 374 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 375 | token: "test-token", | ||
| 376 | session: box.session() | ||
| 377 | ) | ||
| 378 | return (client, box) | ||
| 379 | } | ||
| 380 | |||
| 381 | private let emptyPage = """ | ||
| 382 | {"protocol_version":1,"data":{"items":[]},"exit_code":0} | ||
| 383 | """ | ||
| 384 | private let noLabels = """ | ||
| 385 | {"protocol_version":1,"data":[],"exit_code":0} | ||
| 386 | """ | ||
| 387 | |||
| 388 | /// Every `argv=` value from a request URL, in order. | ||
| 389 | private func argvFrom(_ url: URL) -> [String] { | ||
| 390 | URLComponents(url: url, resolvingAgainstBaseURL: false)? | ||
| 391 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] | ||
| 392 | } | ||
| 393 | |||
| 394 | @MainActor | ||
| 395 | struct IssueListFilterWiringTests { | ||
| 396 | |||
| 397 | @Test func theDefaultReadIsOpenIssues() async throws { | ||
| 398 | let (client, stub) = try makeClient() | ||
| 399 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 400 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 401 | |||
| 402 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 403 | await model.load() | ||
| 404 | |||
| 405 | let read = try #require(stub.seen.first { $0.url.absoluteString.contains("argv=issue") }) | ||
| 406 | #expect(argvFrom(read.url).prefix(6) == [ | ||
| 407 | "issue", "list", "krz/gitbay", "--state", "open", "--limit", | ||
| 408 | ]) | ||
| 409 | } | ||
| 410 | |||
| 411 | @Test func aPickerChangeReloadsWithTheNewFlags() async throws { | ||
| 412 | let (client, stub) = try makeClient() | ||
| 413 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 414 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 415 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 416 | await model.load() | ||
| 417 | |||
| 418 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 419 | model.filter.label = "bug" | ||
| 420 | try await Task.sleep(for: .milliseconds(150)) | ||
| 421 | |||
| 422 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } | ||
| 423 | let last = try #require(reads.last) | ||
| 424 | #expect(argvFrom(last.url).contains("--label")) | ||
| 425 | #expect(argvFrom(last.url).contains("bug")) | ||
| 426 | // Immediate, not debounced: two reads by now. | ||
| 427 | #expect(reads.count == 2) | ||
| 428 | } | ||
| 429 | |||
| 430 | /// Typing must not fire a request per keystroke. | ||
| 431 | @Test func typingDebouncesIntoOneRead() async throws { | ||
| 432 | let (client, stub) = try makeClient() | ||
| 433 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 434 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 435 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 436 | await model.load() | ||
| 437 | |||
| 438 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 439 | for text in ["c", "cr", "cra", "cras", "crash"] { | ||
| 440 | model.filter.search = text | ||
| 441 | try await Task.sleep(for: .milliseconds(20)) | ||
| 442 | } | ||
| 443 | try await Task.sleep(for: .milliseconds(500)) | ||
| 444 | |||
| 445 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } | ||
| 446 | #expect(reads.count == 2) | ||
| 447 | #expect(argvFrom(try #require(reads.last).url).contains("crash")) | ||
| 448 | } | ||
| 449 | |||
| 450 | @Test func theEmptyMessageComesFromTheFilter() async throws { | ||
| 451 | let (client, stub) = try makeClient() | ||
| 452 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 453 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 454 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 455 | await model.load() | ||
| 456 | |||
| 457 | guard case .empty(let message) = model.state else { | ||
| 458 | Testing.Issue.record("expected empty, got \(model.state)") | ||
| 459 | return | ||
| 460 | } | ||
| 461 | #expect(message == "No open issues.") | ||
| 462 | } | ||
| 463 | } | ||
| 464 | |||
| 465 | @MainActor | ||
| 466 | struct MRListFilterWiringTests { | ||
| 467 | |||
| 468 | @Test func searchReachesTheCommand() async throws { | ||
| 469 | let (client, stub) = try makeClient() | ||
| 470 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 471 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 472 | await model.load() | ||
| 473 | |||
| 474 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 475 | model.filter.search = "retarget" | ||
| 476 | try await Task.sleep(for: .milliseconds(500)) | ||
| 477 | |||
| 478 | let last = try #require(stub.seen.last) | ||
| 479 | let argv = argvFrom(last.url) | ||
| 480 | #expect(argv.contains("--search")) | ||
| 481 | #expect(argv.contains("retarget")) | ||
| 482 | } | ||
| 483 | |||
| 484 | @Test func sourceGoneReachesTheCommandInItsWireSpelling() async throws { | ||
| 485 | let (client, stub) = try makeClient() | ||
| 486 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 487 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 488 | await model.load() | ||
| 489 | |||
| 490 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 491 | model.filter.state = .sourceGone | ||
| 492 | try await Task.sleep(for: .milliseconds(150)) | ||
| 493 | |||
| 494 | #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone")) | ||
| 495 | } | ||
| 496 | } | ||
| 497 | ``` | ||
| 498 | |||
| 499 | - [ ] **Step 2: Run to verify failure**, then | ||
| 500 | |||
| 501 | - [ ] **Step 3: Implement in `IssueListViewModel`** | ||
| 502 | |||
| 503 | Replace the `StateFilter` enum and `filter` property: | ||
| 504 | |||
| 505 | ```swift | ||
| 506 | var filter = IssueFilter() { | ||
| 507 | didSet { | ||
| 508 | guard filter != oldValue else { return } | ||
| 509 | configureList() | ||
| 510 | // Only the text changed: debounce, so typing does not fire a | ||
| 511 | // request per keystroke. Anything else is a picker: reload now. | ||
| 512 | var typed = oldValue | ||
| 513 | typed.search = filter.search | ||
| 514 | if typed == filter { | ||
| 515 | scheduleSearchReload() | ||
| 516 | } else { | ||
| 517 | reloadTask?.cancel() | ||
| 518 | reloadTask = Task { await list.reload() } | ||
| 519 | } | ||
| 520 | } | ||
| 521 | } | ||
| 522 | |||
| 523 | /// Open and closed milestones for the filter picker; fetched on first use. | ||
| 524 | private(set) var milestones: [Milestone] = [] | ||
| 525 | private var reloadTask: Task<Void, Never>? | ||
| 526 | |||
| 527 | func loadMilestones() async { | ||
| 528 | guard milestones.isEmpty else { return } | ||
| 529 | milestones = (try? await client.readList( | ||
| 530 | ["milestone", "list", repoPath, "--state", "all"], of: Milestone.self | ||
| 531 | )) ?? [] | ||
| 532 | } | ||
| 533 | |||
| 534 | private func scheduleSearchReload() { | ||
| 535 | reloadTask?.cancel() | ||
| 536 | reloadTask = Task { | ||
| 537 | guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return } | ||
| 538 | guard !Task.isCancelled else { return } | ||
| 539 | await list.reload() | ||
| 540 | } | ||
| 541 | } | ||
| 542 | |||
| 543 | private func configureList() { | ||
| 544 | list.argv = ["issue", "list", repoPath] + filter.flags() | ||
| 545 | list.emptyMessage = filter.emptyMessage | ||
| 546 | } | ||
| 547 | ``` | ||
| 548 | |||
| 549 | Update `init` to build the initial argv the same way: `["issue", "list", repoPath] + IssueFilter().flags()`, and `emptyMessage: IssueFilter().emptyMessage`. | ||
| 550 | |||
| 551 | Apply the identical shape to `MRListViewModel` with `MRFilter`, `["mr", "list", repoPath]`, and the same `loadMilestones()`. `MRListViewModel` does not currently store `client`; add `private let client: GitbayClient` as `IssueListViewModel` already has. | ||
| 552 | |||
| 553 | - [ ] **Step 4: Run the tests**, - [ ] **Step 5: Commit** | ||
| 554 | |||
| 555 | ```bash | ||
| 556 | git commit -m "Both list view models filter and search through their commands" | ||
| 557 | ``` | ||
| 558 | |||
| 559 | --- | ||
| 560 | |||
| 561 | ### Task 3: The filter UI on both lists | ||
| 562 | |||
| 563 | **Files:** | ||
| 564 | - Create: `gitbay/Views/Shared/FilterSheet.swift` | ||
| 565 | - Modify: `gitbay/Views/Issues/IssueListView.swift`, `gitbay/Views/MRs/MRListView.swift` | ||
| 566 | |||
| 567 | No unit tests — UI. Verification is the build plus the suite staying green. | ||
| 568 | |||
| 569 | Both lists get: | ||
| 570 | 1. `.searchable(text: ..., prompt: "Search titles and bodies")` bound to `filter.search`. | ||
| 571 | 2. The existing inline state `Picker` keeps working, retargeted at `filter.state` and the new `State` enums (use `state.label`, not `rawValue.capitalized`, so `source_gone` reads "Source gone"). | ||
| 572 | 3. A toolbar button (`line.3.horizontal.decrease.circle`, filled when `filter.isNarrowed`) opening a sheet. | ||
| 573 | |||
| 574 | `FilterSheet` is a `Form` in a `NavigationStack` with a Done button. It takes bindings and the pickable values, so one view serves both lists: | ||
| 575 | |||
| 576 | ```swift | ||
| 577 | struct FilterSheet<Extra: View>: View { | ||
| 578 | let title: String | ||
| 579 | @Binding var author: String | ||
| 580 | @Binding var milestone: String? | ||
| 581 | let milestones: [Milestone] | ||
| 582 | @ViewBuilder var extra: Extra | ||
| 583 | ... | ||
| 584 | } | ||
| 585 | ``` | ||
| 586 | |||
| 587 | The issue list passes label and assignee through `extra`; the merge request list passes nothing, because `mr list` accepts neither. | ||
| 588 | |||
| 589 | The label row is a `Menu` listing `model.labels` with a `GBChip(name, model.colors.color(name))` per entry plus an "Any label" entry that clears it. The milestone row is a `Menu` over `model.milestones` plus "Any milestone" and "No milestone" (the literal `"none"`). Author and assignee are plain `TextField`s with `.autocorrectionDisabled()` and `.textInputAutocapitalization(.never)` — they are usernames, and no command lists a repository's contributors. | ||
| 590 | |||
| 591 | Call `.task { await model.loadMilestones() }` on the sheet so the picker fills on first open. | ||
| 592 | |||
| 593 | - [ ] **Step 1: Write `FilterSheet`** | ||
| 594 | - [ ] **Step 2: Wire it into `IssueListView`** — `.searchable`, retarget the state picker, add the toolbar button and sheet | ||
| 595 | - [ ] **Step 3: Wire it into `MRListView`** — same, minus label and assignee | ||
| 596 | - [ ] **Step 4: Build and run the full suite.** Expected: still green, no new tests. | ||
| 597 | - [ ] **Step 5: Commit** | ||
| 598 | |||
| 599 | ```bash | ||
| 600 | git commit -m "Filter and search controls on the issue and merge request lists" | ||
| 601 | ``` | ||
| 602 | |||
| 603 | --- | ||
| 604 | |||
| 605 | ### Task 4: Flip the parity rows and open the merge request | ||
| 606 | |||
| 607 | **Files:** `krz/gitbay`, `.gitbay/wiki/Parity.org` | ||
| 608 | |||
| 609 | - [ ] **Step 1: Flip three rows to `yes` in the `ios` column** | ||
| 610 | |||
| 611 | In the Issues table: `filter by label, assignee, author, milestone`, and `search title and body`. | ||
| 612 | In the Merge requests table: `search title and body`. | ||
| 613 | |||
| 614 | Touch no other row. | ||
| 615 | |||
| 616 | **How to land it** — this repo requires signed commits, so only fast-forward merges are permitted, and its working tree usually has unrelated work in it. Use a separate worktree off current `origin/main`, and merge with `--strategy ff`: | ||
| 617 | |||
| 618 | ```bash | ||
| 619 | cd /Users/cmc/git/krz/gitbay | ||
| 620 | git fetch origin main | ||
| 621 | git worktree add -b wiki-parity-ios-filters <tmpdir> origin/main | ||
| 622 | # edit, commit, push | ||
| 623 | gitbay mr create krz/gitbay --source wiki-parity-ios-filters --target main --title "..." --file - | ||
| 624 | gitbay mr merge krz/gitbay <n> --strategy ff | ||
| 625 | git worktree remove <tmpdir> | ||
| 626 | ``` | ||
| 627 | |||
| 628 | If `main` moved between branching and merging, rebase onto it and force-push with lease before merging — `squash` and `rebase` strategies are refused outright because the server cannot sign the commits it would author. | ||
| 629 | |||
| 630 | - [ ] **Step 2: Run the full suite one last time.** Record the real number. | ||
| 631 | |||
| 632 | - [ ] **Step 3: Open the gitbay-ios merge request** | ||
| 633 | |||
| 634 | ```bash | ||
| 635 | gitbay mr create --source list-filters --target main \ | ||
| 636 | --title "Filter and search the issue and merge request lists" --file - <<'EOF' | ||
| 637 | `IssueFilter` and `MRFilter` render themselves to the flags their list | ||
| 638 | commands accept, and both list view models build argv from one. An empty | ||
| 639 | value sends no flag, since `--search ""` is a different query from no | ||
| 640 | search; `--milestone none` is a real filter and survives as one. | ||
| 641 | |||
| 642 | Typing debounces at 300ms the way the repository list already does; | ||
| 643 | changing a picker reloads immediately. | ||
| 644 | |||
| 645 | The issue list filters by label, assignee, author and milestone, with the | ||
| 646 | label picker reading the labels MR 1 already loads. The merge request | ||
| 647 | list offers author and milestone only, because `mr list` accepts neither | ||
| 648 | `--label` nor `--assignee`. | ||
| 649 | |||
| 650 | Closes three parity rows: issue filtering, issue search, and merge | ||
| 651 | request search. | ||
| 652 | EOF | ||
| 653 | ``` | ||
| 654 | |||
| 655 | --- | ||
| 656 | |||
| 657 | ## Notes for whoever executes this | ||
| 658 | |||
| 659 | **The flag list is the contract.** A flag `issue list` does not accept is a server error that surfaces as an empty list, not a crash — which is why Task 1's tests assert exact argv arrays rather than "contains". Do not add a flag because it seems useful; the registry quotes above are exhaustive. | ||
| 660 | |||
| 661 | **`mr list` has no `--label` or `--assignee`.** If you find yourself adding either to `MRFilter`, stop. | ||
| 662 | |||
| 663 | **Do not escape search text.** The server quotes FTS5 term by term, so `c++`, `AND` and a lone quote are all words to match. A test pins this. | ||
| 664 | |||
| 665 | **`source_gone` is the wire spelling.** The Swift case is `sourceGone` with an explicit raw value; the picker shows "Source gone". All three differ and a test pins each. | ||
| 666 | |||
| 667 | **`StateFilter` is deleted from both view models.** Task 3 must retarget the existing pickers, or the build breaks. | ||