Commit 9a10b53c43
Unsigned
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. | ||
gitbay/Issues/IssueFilter.swift added +53
| @@ -0,0 +1,53 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// What `issue list` is narrowed to. Renders itself to the flags the | ||
| 4 | /// command accepts; an empty value sends no flag at all, since | ||
| 5 | /// `--search ""` is a different query from no search. | ||
| 6 | nonisolated struct IssueFilter: Equatable, Sendable { | ||
| 7 | |||
| 8 | enum State: String, CaseIterable, Identifiable, Sendable { | ||
| 9 | case open, closed, all | ||
| 10 | var id: String { rawValue } | ||
| 11 | var label: String { rawValue.capitalized } | ||
| 12 | } | ||
| 13 | |||
| 14 | var state: State = .open | ||
| 15 | var search = "" | ||
| 16 | var label: String? | ||
| 17 | var assignee: String? | ||
| 18 | var author: String? | ||
| 19 | /// A milestone title, or the literal "none" for issues without one. | ||
| 20 | var milestone: String? | ||
| 21 | |||
| 22 | func flags() -> [String] { | ||
| 23 | var flags = ["--state", state.rawValue] | ||
| 24 | flags += filterFlag("--search", search) | ||
| 25 | flags += filterFlag("--label", label) | ||
| 26 | flags += filterFlag("--assignee", assignee) | ||
| 27 | flags += filterFlag("--author", author) | ||
| 28 | flags += filterFlag("--milestone", milestone) | ||
| 29 | return flags | ||
| 30 | } | ||
| 31 | |||
| 32 | /// True when anything beyond the state picker is set, which is what | ||
| 33 | /// decides whether the empty state blames the filters. | ||
| 34 | var isNarrowed: Bool { flags().count > 2 } | ||
| 35 | |||
| 36 | var emptyMessage: String { | ||
| 37 | if isNarrowed { | ||
| 38 | return state == .all | ||
| 39 | ? "No issues match these filters." | ||
| 40 | : "No \(state.rawValue) issues match these filters." | ||
| 41 | } | ||
| 42 | return state == .all ? "No issues." : "No \(state.rawValue) issues." | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | /// One flag, or nothing when the value is absent or blank. Shared by | ||
| 47 | /// both filters, so it is named for what it does rather than taking the | ||
| 48 | /// bare name `flag` in the module namespace. | ||
| 49 | nonisolated func filterFlag(_ name: String, _ value: String?) -> [String] { | ||
| 50 | guard let value else { return [] } | ||
| 51 | let trimmed = value.trimmingCharacters(in: .whitespaces) | ||
| 52 | return trimmed.isEmpty ? [] : [name, trimmed] | ||
| 53 | } | ||
gitbay/Issues/IssueListViewModel.swift +39 −11
| @@ -6,19 +6,26 @@ import Observation | |||
| 6 | @MainActor | 6 | @MainActor |
| 7 | final class IssueListViewModel { | 7 | final class IssueListViewModel { |
| 8 | 8 | ||
| 9 | enum StateFilter: String, CaseIterable, Identifiable, Sendable { | ||
| 10 | case open, closed, all | ||
| 11 | var id: String { rawValue } | ||
| 12 | } | ||
| 13 | |||
| 14 | let list: PagedListModel<Issue> | 9 | let list: PagedListModel<Issue> |
| 15 | let repoPath: String | 10 | let repoPath: String |
| 16 | private let client: GitbayClient | 11 | private let client: GitbayClient |
| 17 | var filter: StateFilter = .open { | 12 | var filter = IssueFilter() { |
| 18 | didSet { | 13 | didSet { |
| 19 | guard filter != oldValue else { return } | 14 | guard filter != oldValue else { return } |
| 20 | configureList() | 15 | configureList() |
| 21 | Task { await list.reload() } | 16 | // Only the free-text fields changed: debounce, so typing does |
| 17 | // not fire a request per keystroke. Anything else — a picker — | ||
| 18 | // reloads now. | ||
| 19 | var typed = oldValue | ||
| 20 | typed.search = filter.search | ||
| 21 | typed.author = filter.author | ||
| 22 | typed.assignee = filter.assignee | ||
| 23 | if typed == filter { | ||
| 24 | scheduleSearchReload() | ||
| 25 | } else { | ||
| 26 | reloadTask?.cancel() | ||
| 27 | reloadTask = Task { await list.reload() } | ||
| 28 | } | ||
| 22 | } | 29 | } |
| 23 | } | 30 | } |
| 24 | 31 | ||
| @@ -31,19 +38,24 @@ final class IssueListViewModel { | |||
| 31 | /// not on every reload. | 38 | /// not on every reload. |
| 32 | private var labelsLoaded = false | 39 | private var labelsLoaded = false |
| 33 | 40 | ||
| 41 | /// Open and closed milestones for the filter picker; fetched on first use. | ||
| 42 | private(set) var milestones: [Milestone] = [] | ||
| 43 | private var reloadTask: Task<Void, Never>? | ||
| 44 | |||
| 34 | init(client: GitbayClient, repoPath: String) { | 45 | init(client: GitbayClient, repoPath: String) { |
| 35 | self.client = client | 46 | self.client = client |
| 36 | self.repoPath = repoPath | 47 | self.repoPath = repoPath |
| 37 | list = PagedListModel( | 48 | list = PagedListModel( |
| 38 | client: client, | 49 | client: client, |
| 39 | argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue], | 50 | argv: ["issue", "list", repoPath] + IssueFilter().flags(), |
| 40 | emptyMessage: "No open issues." | 51 | emptyMessage: IssueFilter().emptyMessage |
| 41 | ) | 52 | ) |
| 42 | } | 53 | } |
| 43 | 54 | ||
| 44 | var state: LoadState<[Issue]> { list.state } | 55 | var state: LoadState<[Issue]> { list.state } |
| 45 | 56 | ||
| 46 | func load() async { | 57 | func load() async { |
| 58 | reloadTask?.cancel() | ||
| 47 | await list.reload() | 59 | await list.reload() |
| 48 | if case .failed = list.state { return } | 60 | if case .failed = list.state { return } |
| 49 | guard !labelsLoaded else { return } | 61 | guard !labelsLoaded else { return } |
| @@ -52,12 +64,28 @@ final class IssueListViewModel { | |||
| 52 | colors = LabelColors(labels) | 64 | colors = LabelColors(labels) |
| 53 | } | 65 | } |
| 54 | 66 | ||
| 67 | func loadMilestones() async { | ||
| 68 | guard milestones.isEmpty else { return } | ||
| 69 | milestones = (try? await client.readList( | ||
| 70 | ["milestone", "list", repoPath, "--state", "all"], of: Milestone.self | ||
| 71 | )) ?? [] | ||
| 72 | } | ||
| 73 | |||
| 55 | private func labelList() async -> [IssueLabel] { | 74 | private func labelList() async -> [IssueLabel] { |
| 56 | (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] | 75 | (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] |
| 57 | } | 76 | } |
| 58 | 77 | ||
| 78 | private func scheduleSearchReload() { | ||
| 79 | reloadTask?.cancel() | ||
| 80 | reloadTask = Task { | ||
| 81 | guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return } | ||
| 82 | guard !Task.isCancelled else { return } | ||
| 83 | await list.reload() | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 59 | private func configureList() { | 87 | private func configureList() { |
| 60 | list.argv = ["issue", "list", repoPath, "--state", filter.rawValue] | 88 | list.argv = ["issue", "list", repoPath] + filter.flags() |
| 61 | list.emptyMessage = "No \(filter == .all ? "" : filter.rawValue + " ")issues." | 89 | list.emptyMessage = filter.emptyMessage |
| 62 | } | 90 | } |
| 63 | } | 91 | } |
gitbay/MRs/MRFilter.swift added +42
| @@ -0,0 +1,42 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// What `mr list` is narrowed to. The command takes no `--label` and no | ||
| 4 | /// `--assignee`, so this offers neither. | ||
| 5 | nonisolated struct MRFilter: Equatable, Sendable { | ||
| 6 | |||
| 7 | enum State: String, CaseIterable, Identifiable, Sendable { | ||
| 8 | case open, merged, closed | ||
| 9 | case sourceGone = "source_gone" | ||
| 10 | case all | ||
| 11 | |||
| 12 | var id: String { rawValue } | ||
| 13 | var label: String { | ||
| 14 | self == .sourceGone ? "Source gone" : rawValue.capitalized | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | var state: State = .open | ||
| 19 | var search = "" | ||
| 20 | var author: String? | ||
| 21 | var milestone: String? | ||
| 22 | |||
| 23 | func flags() -> [String] { | ||
| 24 | var flags = ["--state", state.rawValue] | ||
| 25 | flags += filterFlag("--search", search) | ||
| 26 | flags += filterFlag("--author", author) | ||
| 27 | flags += filterFlag("--milestone", milestone) | ||
| 28 | return flags | ||
| 29 | } | ||
| 30 | |||
| 31 | var isNarrowed: Bool { flags().count > 2 } | ||
| 32 | |||
| 33 | var emptyMessage: String { | ||
| 34 | let noun = "merge requests" | ||
| 35 | if isNarrowed { | ||
| 36 | return state == .all | ||
| 37 | ? "No \(noun) match these filters." | ||
| 38 | : "No \(state.label.lowercased()) \(noun) match these filters." | ||
| 39 | } | ||
| 40 | return state == .all ? "No \(noun)." : "No \(state.label.lowercased()) \(noun)." | ||
| 41 | } | ||
| 42 | } | ||
gitbay/MRs/MRListViewModel.swift +41 −12
| @@ -6,39 +6,68 @@ import Observation | |||
| 6 | @MainActor | 6 | @MainActor |
| 7 | final class MRListViewModel { | 7 | final class MRListViewModel { |
| 8 | 8 | ||
| 9 | enum StateFilter: String, CaseIterable, Identifiable, Sendable { | ||
| 10 | case open, merged, closed, all | ||
| 11 | var id: String { rawValue } | ||
| 12 | } | ||
| 13 | |||
| 14 | let list: PagedListModel<MergeRequest> | 9 | let list: PagedListModel<MergeRequest> |
| 15 | let repoPath: String | 10 | let repoPath: String |
| 16 | var filter: StateFilter = .open { | 11 | private let client: GitbayClient |
| 12 | var filter = MRFilter() { | ||
| 17 | didSet { | 13 | didSet { |
| 18 | guard filter != oldValue else { return } | 14 | guard filter != oldValue else { return } |
| 19 | configureList() | 15 | configureList() |
| 20 | Task { await list.reload() } | 16 | // Only the free-text fields changed: debounce, so typing does |
| 17 | // not fire a request per keystroke. Anything else — a picker — | ||
| 18 | // reloads now. MRFilter has no `assignee` field: `mr list` | ||
| 19 | // rejects it. | ||
| 20 | var typed = oldValue | ||
| 21 | typed.search = filter.search | ||
| 22 | typed.author = filter.author | ||
| 23 | if typed == filter { | ||
| 24 | scheduleSearchReload() | ||
| 25 | } else { | ||
| 26 | reloadTask?.cancel() | ||
| 27 | reloadTask = Task { await list.reload() } | ||
| 28 | } | ||
| 21 | } | 29 | } |
| 22 | } | 30 | } |
| 23 | 31 | ||
| 32 | /// Open and closed milestones for the filter picker; fetched on first use. | ||
| 33 | private(set) var milestones: [Milestone] = [] | ||
| 34 | private var reloadTask: Task<Void, Never>? | ||
| 35 | |||
| 24 | init(client: GitbayClient, repoPath: String) { | 36 | init(client: GitbayClient, repoPath: String) { |
| 37 | self.client = client | ||
| 25 | self.repoPath = repoPath | 38 | self.repoPath = repoPath |
| 26 | list = PagedListModel( | 39 | list = PagedListModel( |
| 27 | client: client, | 40 | client: client, |
| 28 | argv: ["mr", "list", repoPath, "--state", StateFilter.open.rawValue], | 41 | argv: ["mr", "list", repoPath] + MRFilter().flags(), |
| 29 | emptyMessage: "No open merge requests." | 42 | emptyMessage: MRFilter().emptyMessage |
| 30 | ) | 43 | ) |
| 31 | } | 44 | } |
| 32 | 45 | ||
| 33 | var state: LoadState<[MergeRequest]> { list.state } | 46 | var state: LoadState<[MergeRequest]> { list.state } |
| 34 | 47 | ||
| 35 | func load() async { | 48 | func load() async { |
| 49 | reloadTask?.cancel() | ||
| 36 | await list.reload() | 50 | await list.reload() |
| 37 | } | 51 | } |
| 38 | 52 | ||
| 53 | func loadMilestones() async { | ||
| 54 | guard milestones.isEmpty else { return } | ||
| 55 | milestones = (try? await client.readList( | ||
| 56 | ["milestone", "list", repoPath, "--state", "all"], of: Milestone.self | ||
| 57 | )) ?? [] | ||
| 58 | } | ||
| 59 | |||
| 60 | private func scheduleSearchReload() { | ||
| 61 | reloadTask?.cancel() | ||
| 62 | reloadTask = Task { | ||
| 63 | guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return } | ||
| 64 | guard !Task.isCancelled else { return } | ||
| 65 | await list.reload() | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 39 | private func configureList() { | 69 | private func configureList() { |
| 40 | list.argv = ["mr", "list", repoPath, "--state", filter.rawValue] | 70 | list.argv = ["mr", "list", repoPath] + filter.flags() |
| 41 | list.emptyMessage = | 71 | list.emptyMessage = filter.emptyMessage |
| 42 | "No \(filter == .all ? "" : filter.rawValue + " ")merge requests." | ||
| 43 | } | 72 | } |
| 44 | } | 73 | } |
gitbay/Repos/PagedListModel.swift +5 −2
| @@ -59,8 +59,11 @@ final class PagedListModel<Element: Decodable & Sendable> { | |||
| 59 | } catch { | 59 | } catch { |
| 60 | // A failed first page is the screen's state; a failed later | 60 | // A failed first page is the screen's state; a failed later |
| 61 | // page keeps what is on screen and leaves the cursor for a | 61 | // page keeps what is on screen and leaves the cursor for a |
| 62 | // retry from the same trigger. | 62 | // retry from the same trigger. A cancelled fetch (superseded |
| 63 | if existing.isEmpty { | 63 | // by a newer reload, e.g. a fast filter change) is neither — |
| 64 | // it must not paint a stale failure over whatever the | ||
| 65 | // replacement fetch already showed or is about to show. | ||
| 66 | if existing.isEmpty, !Task.isCancelled { | ||
| 64 | state = .from(error) | 67 | state = .from(error) |
| 65 | } | 68 | } |
| 66 | } | 69 | } |
gitbay/Views/Issues/IssueListView.swift +35 −3
| @@ -5,6 +5,7 @@ struct IssueListView: View { | |||
| 5 | @State private var model: IssueListViewModel | 5 | @State private var model: IssueListViewModel |
| 6 | @State private var createModel: IssueCreateViewModel | 6 | @State private var createModel: IssueCreateViewModel |
| 7 | @State private var composing = false | 7 | @State private var composing = false |
| 8 | @State private var filtering = false | ||
| 8 | @State private var draftTitle = "" | 9 | @State private var draftTitle = "" |
| 9 | @State private var draftBody = "" | 10 | @State private var draftBody = "" |
| 10 | 11 | ||
| @@ -15,9 +16,9 @@ struct IssueListView: View { | |||
| 15 | 16 | ||
| 16 | var body: some View { | 17 | var body: some View { |
| 17 | List { | 18 | List { |
| 18 | Picker("State", selection: Bindable(model).filter) { | 19 | Picker("State", selection: Bindable(model).filter.state) { |
| 19 | ForEach(IssueListViewModel.StateFilter.allCases) { filter in | 20 | ForEach(IssueFilter.State.allCases) { filter in |
| 20 | Text(filter.rawValue.capitalized).tag(filter) | 21 | Text(filter.label).tag(filter) |
| 21 | } | 22 | } |
| 22 | } | 23 | } |
| 23 | .pickerStyle(.segmented) | 24 | .pickerStyle(.segmented) |
| @@ -32,9 +33,20 @@ struct IssueListView: View { | |||
| 32 | PageFooter(list: model.list) | 33 | PageFooter(list: model.list) |
| 33 | } | 34 | } |
| 34 | .overlay { LoadStateOverlay(state: model.state) } | 35 | .overlay { LoadStateOverlay(state: model.state) } |
| 36 | .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies") | ||
| 35 | .navigationTitle("Issues") | 37 | .navigationTitle("Issues") |
| 36 | .navigationBarTitleDisplayMode(.inline) | 38 | .navigationBarTitleDisplayMode(.inline) |
| 37 | .toolbar { | 39 | .toolbar { |
| 40 | ToolbarItem(placement: .topBarTrailing) { | ||
| 41 | Button { | ||
| 42 | filtering = true | ||
| 43 | } label: { | ||
| 44 | Image(systemName: model.filter.isNarrowed | ||
| 45 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 46 | : "line.3.horizontal.decrease.circle") | ||
| 47 | } | ||
| 48 | .accessibilityIdentifier("issue-filter-button") | ||
| 49 | } | ||
| 38 | ToolbarItem(placement: .topBarTrailing) { | 50 | ToolbarItem(placement: .topBarTrailing) { |
| 39 | Button { | 51 | Button { |
| 40 | composing = true | 52 | composing = true |
| @@ -44,6 +56,26 @@ struct IssueListView: View { | |||
| 44 | .accessibilityIdentifier("issue-create-button") | 56 | .accessibilityIdentifier("issue-create-button") |
| 45 | } | 57 | } |
| 46 | } | 58 | } |
| 59 | .sheet(isPresented: $filtering) { | ||
| 60 | FilterSheet( | ||
| 61 | title: "Filter Issues", | ||
| 62 | author: Bindable(model).filter.author, | ||
| 63 | milestone: Bindable(model).filter.milestone, | ||
| 64 | milestones: model.milestones, | ||
| 65 | onClear: { | ||
| 66 | let state = model.filter.state | ||
| 67 | model.filter = IssueFilter(state: state) | ||
| 68 | } | ||
| 69 | ) { | ||
| 70 | IssueFilterExtra( | ||
| 71 | label: Bindable(model).filter.label, | ||
| 72 | assignee: Bindable(model).filter.assignee, | ||
| 73 | labels: model.labels, | ||
| 74 | colors: model.colors | ||
| 75 | ) | ||
| 76 | } | ||
| 77 | .task { await model.loadMilestones() } | ||
| 78 | } | ||
| 47 | .sheet(isPresented: $composing) { | 79 | .sheet(isPresented: $composing) { |
| 48 | ComposeSheet( | 80 | ComposeSheet( |
| 49 | heading: "New Issue", | 81 | heading: "New Issue", |
gitbay/Views/MRs/MRListView.swift +28 −3
| @@ -5,6 +5,7 @@ struct MRListView: View { | |||
| 5 | @State private var model: MRListViewModel | 5 | @State private var model: MRListViewModel |
| 6 | @State private var createModel: MRCreateViewModel | 6 | @State private var createModel: MRCreateViewModel |
| 7 | @State private var composing = false | 7 | @State private var composing = false |
| 8 | @State private var filtering = false | ||
| 8 | 9 | ||
| 9 | init(client: GitbayClient, repo: String) { | 10 | init(client: GitbayClient, repo: String) { |
| 10 | _model = State(initialValue: MRListViewModel(client: client, repoPath: repo)) | 11 | _model = State(initialValue: MRListViewModel(client: client, repoPath: repo)) |
| @@ -13,9 +14,9 @@ struct MRListView: View { | |||
| 13 | 14 | ||
| 14 | var body: some View { | 15 | var body: some View { |
| 15 | List { | 16 | List { |
| 16 | Picker("State", selection: Bindable(model).filter) { | 17 | Picker("State", selection: Bindable(model).filter.state) { |
| 17 | ForEach(MRListViewModel.StateFilter.allCases) { filter in | 18 | ForEach(MRFilter.State.allCases) { filter in |
| 18 | Text(filter.rawValue.capitalized).tag(filter) | 19 | Text(filter.label).tag(filter) |
| 19 | } | 20 | } |
| 20 | } | 21 | } |
| 21 | .pickerStyle(.segmented) | 22 | .pickerStyle(.segmented) |
| @@ -30,9 +31,20 @@ struct MRListView: View { | |||
| 30 | PageFooter(list: model.list) | 31 | PageFooter(list: model.list) |
| 31 | } | 32 | } |
| 32 | .overlay { LoadStateOverlay(state: model.state) } | 33 | .overlay { LoadStateOverlay(state: model.state) } |
| 34 | .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies") | ||
| 33 | .navigationTitle("Merge Requests") | 35 | .navigationTitle("Merge Requests") |
| 34 | .navigationBarTitleDisplayMode(.inline) | 36 | .navigationBarTitleDisplayMode(.inline) |
| 35 | .toolbar { | 37 | .toolbar { |
| 38 | ToolbarItem(placement: .topBarTrailing) { | ||
| 39 | Button { | ||
| 40 | filtering = true | ||
| 41 | } label: { | ||
| 42 | Image(systemName: model.filter.isNarrowed | ||
| 43 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 44 | : "line.3.horizontal.decrease.circle") | ||
| 45 | } | ||
| 46 | .accessibilityIdentifier("mr-filter-button") | ||
| 47 | } | ||
| 36 | ToolbarItem(placement: .topBarTrailing) { | 48 | ToolbarItem(placement: .topBarTrailing) { |
| 37 | Button { | 49 | Button { |
| 38 | composing = true | 50 | composing = true |
| @@ -42,6 +54,19 @@ struct MRListView: View { | |||
| 42 | .accessibilityIdentifier("mr-create-button") | 54 | .accessibilityIdentifier("mr-create-button") |
| 43 | } | 55 | } |
| 44 | } | 56 | } |
| 57 | .sheet(isPresented: $filtering) { | ||
| 58 | FilterSheet( | ||
| 59 | title: "Filter Merge Requests", | ||
| 60 | author: Bindable(model).filter.author, | ||
| 61 | milestone: Bindable(model).filter.milestone, | ||
| 62 | milestones: model.milestones, | ||
| 63 | onClear: { | ||
| 64 | let state = model.filter.state | ||
| 65 | model.filter = MRFilter(state: state) | ||
| 66 | } | ||
| 67 | ) {} | ||
| 68 | .task { await model.loadMilestones() } | ||
| 69 | } | ||
| 45 | .sheet(isPresented: $composing) { | 70 | .sheet(isPresented: $composing) { |
| 46 | MRCreateSheet(model: createModel) { | 71 | MRCreateSheet(model: createModel) { |
| 47 | composing = false | 72 | composing = false |
gitbay/Views/Shared/FilterSheet.swift added +115
| @@ -0,0 +1,115 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// The issue and MR lists' filter sheet. A `Form` with the fields both | ||
| 4 | /// lists share — author and milestone — plus an `extra` slot for the | ||
| 5 | /// rows only issues have, since `mr list` takes neither `--label` nor | ||
| 6 | /// `--assignee`. | ||
| 7 | struct FilterSheet<Extra: View>: View { | ||
| 8 | |||
| 9 | let title: String | ||
| 10 | @Binding var author: String? | ||
| 11 | @Binding var milestone: String? | ||
| 12 | let milestones: [Milestone] | ||
| 13 | let onClear: () -> Void | ||
| 14 | @ViewBuilder var extra: Extra | ||
| 15 | |||
| 16 | @Environment(\.dismiss) private var dismiss | ||
| 17 | |||
| 18 | var body: some View { | ||
| 19 | NavigationStack { | ||
| 20 | Form { | ||
| 21 | extra | ||
| 22 | Section("Author") { | ||
| 23 | TextField("Username", text: $author.orEmpty) | ||
| 24 | .autocorrectionDisabled() | ||
| 25 | .textInputAutocapitalization(.never) | ||
| 26 | .accessibilityIdentifier("filter-author") | ||
| 27 | } | ||
| 28 | Section("Milestone") { | ||
| 29 | Menu { | ||
| 30 | Button("Any milestone") { milestone = nil } | ||
| 31 | Button("No milestone") { milestone = "none" } | ||
| 32 | ForEach(milestones) { entry in | ||
| 33 | Button(entry.title) { milestone = entry.title } | ||
| 34 | } | ||
| 35 | } label: { | ||
| 36 | HStack { | ||
| 37 | Text(milestone ?? "Any milestone") | ||
| 38 | Spacer() | ||
| 39 | Image(systemName: "chevron.up.chevron.down") | ||
| 40 | .font(.gbSans(.caption2)) | ||
| 41 | .foregroundStyle(.secondary) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | .accessibilityIdentifier("filter-milestone-menu") | ||
| 45 | } | ||
| 46 | Section { | ||
| 47 | Button("Clear filters", role: .destructive, action: onClear) | ||
| 48 | } | ||
| 49 | } | ||
| 50 | .navigationTitle(title) | ||
| 51 | .navigationBarTitleDisplayMode(.inline) | ||
| 52 | .toolbar { | ||
| 53 | ToolbarItem(placement: .confirmationAction) { | ||
| 54 | Button("Done") { dismiss() } | ||
| 55 | } | ||
| 56 | } | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | /// The label and assignee rows the issue list's sheet adds through | ||
| 62 | /// `FilterSheet`'s `extra` slot. | ||
| 63 | struct IssueFilterExtra: View { | ||
| 64 | |||
| 65 | @Binding var label: String? | ||
| 66 | @Binding var assignee: String? | ||
| 67 | let labels: [IssueLabel] | ||
| 68 | let colors: LabelColors | ||
| 69 | |||
| 70 | var body: some View { | ||
| 71 | Section("Label") { | ||
| 72 | Menu { | ||
| 73 | Button("Any label") { label = nil } | ||
| 74 | ForEach(labels) { entry in | ||
| 75 | Button { | ||
| 76 | label = entry.name | ||
| 77 | } label: { | ||
| 78 | Text(entry.name) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | } label: { | ||
| 82 | HStack { | ||
| 83 | if let label { | ||
| 84 | GBChip(label, colors.color(label)) | ||
| 85 | } else { | ||
| 86 | Text("Any label") | ||
| 87 | } | ||
| 88 | Spacer() | ||
| 89 | Image(systemName: "chevron.up.chevron.down") | ||
| 90 | .font(.gbSans(.caption2)) | ||
| 91 | .foregroundStyle(.secondary) | ||
| 92 | } | ||
| 93 | } | ||
| 94 | .accessibilityIdentifier("filter-label-menu") | ||
| 95 | } | ||
| 96 | Section("Assignee") { | ||
| 97 | TextField("Username", text: $assignee.orEmpty) | ||
| 98 | .autocorrectionDisabled() | ||
| 99 | .textInputAutocapitalization(.never) | ||
| 100 | .accessibilityIdentifier("filter-assignee") | ||
| 101 | } | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | extension Binding where Value == String? { | ||
| 106 | /// Bridges an optional filter field to a plain `TextField`: empty | ||
| 107 | /// text writes back `nil` rather than `""`, since the two are | ||
| 108 | /// distinct filter values (no flag sent versus `--flag ""`). | ||
| 109 | var orEmpty: Binding<String> { | ||
| 110 | Binding<String>( | ||
| 111 | get: { self.wrappedValue ?? "" }, | ||
| 112 | set: { self.wrappedValue = $0.isEmpty ? nil : $0 } | ||
| 113 | ) | ||
| 114 | } | ||
| 115 | } | ||
gitbayTests/ListFilterTests.swift added +373
| @@ -0,0 +1,373 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | struct IssueFilterTests { | ||
| 6 | |||
| 7 | @Test func theDefaultIsOpenAndNothingElse() { | ||
| 8 | #expect(IssueFilter().flags() == ["--state", "open"]) | ||
| 9 | #expect(IssueFilter().isNarrowed == false) | ||
| 10 | } | ||
| 11 | |||
| 12 | @Test func stateIsAlwaysFirst() { | ||
| 13 | var f = IssueFilter() | ||
| 14 | f.state = .closed | ||
| 15 | f.author = "cmc" | ||
| 16 | #expect(f.flags().prefix(2) == ["--state", "closed"]) | ||
| 17 | } | ||
| 18 | |||
| 19 | @Test func eachFieldRendersItsOwnFlag() { | ||
| 20 | var f = IssueFilter() | ||
| 21 | f.label = "bug" | ||
| 22 | f.assignee = "cmc" | ||
| 23 | f.author = "rae" | ||
| 24 | f.milestone = "v1.0.0" | ||
| 25 | f.search = "crash on launch" | ||
| 26 | #expect(f.flags() == [ | ||
| 27 | "--state", "open", | ||
| 28 | "--search", "crash on launch", | ||
| 29 | "--label", "bug", | ||
| 30 | "--assignee", "cmc", | ||
| 31 | "--author", "rae", | ||
| 32 | "--milestone", "v1.0.0", | ||
| 33 | ]) | ||
| 34 | #expect(f.isNarrowed) | ||
| 35 | } | ||
| 36 | |||
| 37 | /// An empty value must send no flag. `--search ""` is a different | ||
| 38 | /// query from no search at all. | ||
| 39 | @Test func emptyAndWhitespaceValuesSendNoFlag() { | ||
| 40 | var f = IssueFilter() | ||
| 41 | f.search = " " | ||
| 42 | f.label = "" | ||
| 43 | f.assignee = nil | ||
| 44 | f.author = " " | ||
| 45 | f.milestone = "" | ||
| 46 | #expect(f.flags() == ["--state", "open"]) | ||
| 47 | #expect(f.isNarrowed == false) | ||
| 48 | } | ||
| 49 | |||
| 50 | /// "none" is a real milestone filter meaning "has no milestone". | ||
| 51 | @Test func milestoneNoneIsAValueNotAnAbsence() { | ||
| 52 | var f = IssueFilter() | ||
| 53 | f.milestone = "none" | ||
| 54 | #expect(f.flags() == ["--state", "open", "--milestone", "none"]) | ||
| 55 | #expect(f.isNarrowed) | ||
| 56 | } | ||
| 57 | |||
| 58 | /// The server quotes FTS5 terms itself, so an operator is a word to | ||
| 59 | /// match. The app must not escape or reject it. | ||
| 60 | @Test func searchTextIsPassedThroughUnquoted() { | ||
| 61 | var f = IssueFilter() | ||
| 62 | f.search = "c++ AND \"quoted" | ||
| 63 | #expect(f.flags() == ["--state", "open", "--search", "c++ AND \"quoted"]) | ||
| 64 | } | ||
| 65 | |||
| 66 | @Test func surroundingWhitespaceIsTrimmedFromValues() { | ||
| 67 | var f = IssueFilter() | ||
| 68 | f.search = " crash " | ||
| 69 | f.label = " bug " | ||
| 70 | #expect(f.flags() == ["--state", "open", "--search", "crash", "--label", "bug"]) | ||
| 71 | } | ||
| 72 | |||
| 73 | @Test func theEmptyMessageNamesTheNarrowing() { | ||
| 74 | #expect(IssueFilter().emptyMessage == "No open issues.") | ||
| 75 | var all = IssueFilter() | ||
| 76 | all.state = .all | ||
| 77 | #expect(all.emptyMessage == "No issues.") | ||
| 78 | var narrowed = IssueFilter() | ||
| 79 | narrowed.label = "bug" | ||
| 80 | #expect(narrowed.emptyMessage == "No open issues match these filters.") | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | struct MRFilterTests { | ||
| 85 | |||
| 86 | @Test func theDefaultIsOpenAndNothingElse() { | ||
| 87 | #expect(MRFilter().flags() == ["--state", "open"]) | ||
| 88 | #expect(MRFilter().isNarrowed == false) | ||
| 89 | } | ||
| 90 | |||
| 91 | @Test func eachFieldRendersItsOwnFlag() { | ||
| 92 | var f = MRFilter() | ||
| 93 | f.author = "cmc" | ||
| 94 | f.milestone = "v1.0.0" | ||
| 95 | f.search = "retarget" | ||
| 96 | #expect(f.flags() == [ | ||
| 97 | "--state", "open", | ||
| 98 | "--search", "retarget", | ||
| 99 | "--author", "cmc", | ||
| 100 | "--milestone", "v1.0.0", | ||
| 101 | ]) | ||
| 102 | } | ||
| 103 | |||
| 104 | /// `mr list` accepts source_gone; the spelling is the wire spelling, | ||
| 105 | /// not the Swift case name. | ||
| 106 | @Test func sourceGoneSpellsItselfTheWayTheCommandDoes() { | ||
| 107 | var f = MRFilter() | ||
| 108 | f.state = .sourceGone | ||
| 109 | #expect(f.flags() == ["--state", "source_gone"]) | ||
| 110 | #expect(MRFilter.State.sourceGone.label == "Source gone") | ||
| 111 | } | ||
| 112 | |||
| 113 | @Test func everyStateTheCommandAcceptsIsOffered() { | ||
| 114 | #expect(MRFilter.State.allCases.map(\.rawValue) | ||
| 115 | == ["open", "merged", "closed", "source_gone", "all"]) | ||
| 116 | } | ||
| 117 | |||
| 118 | @Test func emptyValuesSendNoFlag() { | ||
| 119 | var f = MRFilter() | ||
| 120 | f.search = " " | ||
| 121 | f.author = "" | ||
| 122 | #expect(f.flags() == ["--state", "open"]) | ||
| 123 | } | ||
| 124 | |||
| 125 | @Test func theEmptyMessageNamesTheNarrowing() { | ||
| 126 | #expect(MRFilter().emptyMessage == "No open merge requests.") | ||
| 127 | var narrowed = MRFilter() | ||
| 128 | narrowed.search = "x" | ||
| 129 | #expect(narrowed.emptyMessage == "No open merge requests match these filters.") | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 134 | let box = StubProtocol.box() | ||
| 135 | let client = GitbayClient( | ||
| 136 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 137 | token: "test-token", | ||
| 138 | session: box.session() | ||
| 139 | ) | ||
| 140 | return (client, box) | ||
| 141 | } | ||
| 142 | |||
| 143 | private let emptyPage = """ | ||
| 144 | {"protocol_version":1,"data":{"items":[]},"exit_code":0} | ||
| 145 | """ | ||
| 146 | private let noLabels = """ | ||
| 147 | {"protocol_version":1,"data":[],"exit_code":0} | ||
| 148 | """ | ||
| 149 | private let pageWithCursor = """ | ||
| 150 | {"protocol_version":1,"data":{"items":[\ | ||
| 151 | {"number":7,"title":"a bug","state":"open","author":"cmc",\ | ||
| 152 | "created_at":"2026-09-01T00:00:00Z"}],"next":"c1"},"exit_code":0} | ||
| 153 | """ | ||
| 154 | |||
| 155 | /// Every `argv=` value from a request URL, in order. | ||
| 156 | private func argvFrom(_ url: URL) -> [String] { | ||
| 157 | URLComponents(url: url, resolvingAgainstBaseURL: false)? | ||
| 158 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] | ||
| 159 | } | ||
| 160 | |||
| 161 | @MainActor | ||
| 162 | struct IssueListFilterWiringTests { | ||
| 163 | |||
| 164 | @Test func theDefaultReadIsOpenIssues() async throws { | ||
| 165 | let (client, stub) = try makeClient() | ||
| 166 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 167 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 168 | |||
| 169 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 170 | await model.load() | ||
| 171 | |||
| 172 | let read = try #require(stub.seen.first { $0.url.absoluteString.contains("argv=issue") }) | ||
| 173 | #expect(argvFrom(read.url).prefix(6) == [ | ||
| 174 | "issue", "list", "krz/gitbay", "--state", "open", "--limit", | ||
| 175 | ]) | ||
| 176 | } | ||
| 177 | |||
| 178 | @Test func aPickerChangeReloadsWithTheNewFlags() async throws { | ||
| 179 | let (client, stub) = try makeClient() | ||
| 180 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 181 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 182 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 183 | await model.load() | ||
| 184 | |||
| 185 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 186 | model.filter.label = "bug" | ||
| 187 | try await Task.sleep(for: .milliseconds(150)) | ||
| 188 | |||
| 189 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } | ||
| 190 | let last = try #require(reads.last) | ||
| 191 | #expect(argvFrom(last.url).contains("--label")) | ||
| 192 | #expect(argvFrom(last.url).contains("bug")) | ||
| 193 | // Immediate, not debounced: two reads by now. | ||
| 194 | #expect(reads.count == 2) | ||
| 195 | } | ||
| 196 | |||
| 197 | /// Typing must not fire a request per keystroke. | ||
| 198 | @Test func typingDebouncesIntoOneRead() async throws { | ||
| 199 | let (client, stub) = try makeClient() | ||
| 200 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 201 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 202 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 203 | await model.load() | ||
| 204 | |||
| 205 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 206 | // No sleeps between keystrokes: `Task.sleep` guarantees a minimum | ||
| 207 | // wait, not a maximum, so any inter-keystroke delay is a window | ||
| 208 | // for the debounce timer to fire early on a loaded machine. Typing | ||
| 209 | // the whole string with no `await` between assignments removes | ||
| 210 | // that window; only the final wait — comfortably past the 300ms | ||
| 211 | // debounce — needs headroom. | ||
| 212 | for text in ["c", "cr", "cra", "cras", "crash"] { | ||
| 213 | model.filter.search = text | ||
| 214 | } | ||
| 215 | try await Task.sleep(for: .milliseconds(600)) | ||
| 216 | |||
| 217 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } | ||
| 218 | #expect(reads.count == 2) | ||
| 219 | #expect(argvFrom(try #require(reads.last).url).contains("crash")) | ||
| 220 | } | ||
| 221 | |||
| 222 | /// Typing into `author` must debounce too — it is free text, exactly | ||
| 223 | /// like `search`, and firing a request per keystroke would hit the | ||
| 224 | /// rate limit on an 8-character username. | ||
| 225 | @Test func authorDebouncesIntoOneRead() async throws { | ||
| 226 | let (client, stub) = try makeClient() | ||
| 227 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 228 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 229 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 230 | await model.load() | ||
| 231 | |||
| 232 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 233 | for text in ["c", "cm", "cmc"] { | ||
| 234 | model.filter.author = text | ||
| 235 | } | ||
| 236 | try await Task.sleep(for: .milliseconds(600)) | ||
| 237 | |||
| 238 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } | ||
| 239 | #expect(reads.count == 2) | ||
| 240 | let argv = argvFrom(try #require(reads.last).url) | ||
| 241 | #expect(argv.contains("--author")) | ||
| 242 | #expect(argv.contains("cmc")) | ||
| 243 | } | ||
| 244 | |||
| 245 | /// Same for `assignee`, issue-list-only. | ||
| 246 | @Test func assigneeDebouncesIntoOneRead() async throws { | ||
| 247 | let (client, stub) = try makeClient() | ||
| 248 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 249 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 250 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 251 | await model.load() | ||
| 252 | |||
| 253 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 254 | for text in ["c", "cm", "cmc"] { | ||
| 255 | model.filter.assignee = text | ||
| 256 | } | ||
| 257 | try await Task.sleep(for: .milliseconds(600)) | ||
| 258 | |||
| 259 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } | ||
| 260 | #expect(reads.count == 2) | ||
| 261 | let argv = argvFrom(try #require(reads.last).url) | ||
| 262 | #expect(argv.contains("--assignee")) | ||
| 263 | #expect(argv.contains("cmc")) | ||
| 264 | } | ||
| 265 | |||
| 266 | /// A filter change reloads rather than pages, so it must not carry | ||
| 267 | /// forward a cursor from whatever page was on screen. | ||
| 268 | @Test func aFilterChangeDoesNotCarryAStaleCursor() async throws { | ||
| 269 | let (client, stub) = try makeClient() | ||
| 270 | stub.enqueue(.init(status: 200, json: pageWithCursor, match: "argv=issue")) | ||
| 271 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 272 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 273 | await model.load() | ||
| 274 | #expect(model.list.hasMore) | ||
| 275 | |||
| 276 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 277 | model.filter.label = "bug" | ||
| 278 | try await Task.sleep(for: .milliseconds(150)) | ||
| 279 | |||
| 280 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } | ||
| 281 | let last = try #require(reads.last) | ||
| 282 | #expect(!argvFrom(last.url).contains("--cursor")) | ||
| 283 | } | ||
| 284 | |||
| 285 | /// Two rapid picker changes cancel the first reload's task. The | ||
| 286 | /// cancelled fetch must not paint a bogus "check your connection" | ||
| 287 | /// failure over the correct state the second reload lands. | ||
| 288 | @Test func rapidFilterChangesDoNotLeaveTheScreenFailed() async throws { | ||
| 289 | let (client, stub) = try makeClient() | ||
| 290 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 291 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 292 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 293 | await model.load() | ||
| 294 | |||
| 295 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 296 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 297 | model.filter.label = "bug" | ||
| 298 | model.filter.label = "feature" | ||
| 299 | try await Task.sleep(for: .milliseconds(200)) | ||
| 300 | |||
| 301 | if case .failed(let message) = model.state { | ||
| 302 | Testing.Issue.record("rapid filter changes left the screen failed: \(message)") | ||
| 303 | } | ||
| 304 | } | ||
| 305 | |||
| 306 | @Test func theEmptyMessageComesFromTheFilter() async throws { | ||
| 307 | let (client, stub) = try makeClient() | ||
| 308 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 309 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 310 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 311 | await model.load() | ||
| 312 | |||
| 313 | guard case .empty(let message) = model.state else { | ||
| 314 | Testing.Issue.record("expected empty, got \(model.state)") | ||
| 315 | return | ||
| 316 | } | ||
| 317 | #expect(message == "No open issues.") | ||
| 318 | } | ||
| 319 | } | ||
| 320 | |||
| 321 | @MainActor | ||
| 322 | struct MRListFilterWiringTests { | ||
| 323 | |||
| 324 | @Test func searchReachesTheCommand() async throws { | ||
| 325 | let (client, stub) = try makeClient() | ||
| 326 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 327 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 328 | await model.load() | ||
| 329 | |||
| 330 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 331 | model.filter.search = "retarget" | ||
| 332 | try await Task.sleep(for: .milliseconds(500)) | ||
| 333 | |||
| 334 | let last = try #require(stub.seen.last) | ||
| 335 | let argv = argvFrom(last.url) | ||
| 336 | #expect(argv.contains("--search")) | ||
| 337 | #expect(argv.contains("retarget")) | ||
| 338 | } | ||
| 339 | |||
| 340 | /// Typing into `author` must debounce into one read, same as `search` | ||
| 341 | /// — and, unlike the other tests in this struct, actually count the | ||
| 342 | /// requests rather than just inspecting the last one. | ||
| 343 | @Test func authorDebouncesIntoOneRead() async throws { | ||
| 344 | let (client, stub) = try makeClient() | ||
| 345 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 346 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 347 | await model.load() | ||
| 348 | |||
| 349 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 350 | for text in ["c", "cm", "cmc"] { | ||
| 351 | model.filter.author = text | ||
| 352 | } | ||
| 353 | try await Task.sleep(for: .milliseconds(600)) | ||
| 354 | |||
| 355 | #expect(stub.seen.count == 2) | ||
| 356 | let argv = argvFrom(try #require(stub.seen.last).url) | ||
| 357 | #expect(argv.contains("--author")) | ||
| 358 | #expect(argv.contains("cmc")) | ||
| 359 | } | ||
| 360 | |||
| 361 | @Test func sourceGoneReachesTheCommandInItsWireSpelling() async throws { | ||
| 362 | let (client, stub) = try makeClient() | ||
| 363 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 364 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 365 | await model.load() | ||
| 366 | |||
| 367 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 368 | model.filter.state = .sourceGone | ||
| 369 | try await Task.sleep(for: .milliseconds(150)) | ||
| 370 | |||
| 371 | #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone")) | ||
| 372 | } | ||
| 373 | } | ||
gitbayTests/MRViewModelTests.swift +1 −1
| @@ -171,7 +171,7 @@ struct MRListViewModelTests { | |||
| 171 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | 171 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") |
| 172 | await model.load() | 172 | await model.load() |
| 173 | 173 | ||
| 174 | model.filter = .merged | 174 | model.filter.state = .merged |
| 175 | // The reload happens in a spawned task; give it a beat. | 175 | // The reload happens in a spawned task; give it a beat. |
| 176 | try await Task.sleep(for: .milliseconds(300)) | 176 | try await Task.sleep(for: .milliseconds(300)) |
| 177 | 177 | ||
gitbayTests/PagedListTests.swift +16
| @@ -111,6 +111,22 @@ struct PagedListModelTests { | |||
| 111 | #expect(list.state.value?.map(\.number) == [1, 2, 3]) | 111 | #expect(list.state.value?.map(\.number) == [1, 2, 3]) |
| 112 | } | 112 | } |
| 113 | 113 | ||
| 114 | /// A cancelled reload (e.g. superseded by a newer one from a fast | ||
| 115 | /// filter change) must not write a `.failed` state — whatever error | ||
| 116 | /// the cancellation surfaces as, it belongs to a fetch nobody wants | ||
| 117 | /// the result of any more. | ||
| 118 | @Test func aCancelledReloadDoesNotWriteAFailedState() async throws { | ||
| 119 | let (list, _) = try makeList() | ||
| 120 | |||
| 121 | let task = Task { await list.reload() } | ||
| 122 | task.cancel() | ||
| 123 | await task.value | ||
| 124 | |||
| 125 | if case .failed(let message) = list.state { | ||
| 126 | Issue.record("cancelled reload left the screen failed: \(message)") | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 114 | @Test func reloadDropsTheOldCursorAndItems() async throws { | 130 | @Test func reloadDropsTheOldCursorAndItems() async throws { |
| 115 | let (list, stub) = try makeList() | 131 | let (list, stub) = try makeList() |
| 116 | stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) | 132 | stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) |