Commit 3d76c278d1

3d76c278d1ff9de72c3c71dc8185990772816454

parent: 98eceac833

Unsigned

cmc <hello@cleberg.net> · 2026-08-27 06:29 UTC

adopt the dashboard aggregate and cursor pagination

The dashboard is one read now: the dashboard command (krz/gitbay#41)
returns pinned repos, open MRs, assigned issues and recent builds with
repos resolved server-side. Replaces the per-repo fan-out that cost
~130 reads for 66 repos and drained the 120/min bucket at repo 64.
Pinned repos appear on the dashboard for the first time — they were
write-only over the API before the aggregate.

repo list, issue list and mr list are paginated (krz/gitbay#40):
GitbayClient.readPage sends --limit/--cursor and decodes the
{items, next} shape; PagedListModel accumulates pages and a footer row
fetches the next page as it scrolls into view. Cursors are opaque and
server-minted — the client only hands back what next carried. A failed
later page keeps the list and the cursor for a retry; a reload drops
both. Lists now render in server order instead of re-sorting client-side,
since client-side sorting across pages would fight keyset order.

Live verification: dashboard confirmed in the app against gitbay.org;
pagination shapes and a real cursor round-trip confirmed against
production over SSH.

Closes the adoption half of #41 and #40.

Ref #11

Layout: unified · split

gitbay/ContentView.swift +1 −1
@@ -9,7 +9,7 @@ struct ContentView: View {
9 TabView { 9 TabView {
10 Tab("Dashboard", systemImage: "square.grid.2x2") { 10 Tab("Dashboard", systemImage: "square.grid.2x2") {
11 NavigationStack { 11 NavigationStack {
12 DashboardView(client: client, username: account.username) 12 DashboardView(client: client)
13 .navigationDestinations(client: client) 13 .navigationDestinations(client: client)
14 } 14 }
15 } 15 }
gitbay/Dashboard/DashboardModels.swift added +54
@@ -0,0 +1,54 @@
1import Foundation
2
3/// The `dashboard` command: the whole account aggregate in one read.
4/// `internal/control/dashboard.go` — arrays are always present.
5nonisolated struct DashboardData: Decodable, Sendable, Hashable {
6 /// Pinned repos share `repo list`'s row shape.
7 let pinned: [RepoSummary]
8 let openMRs: [DashboardItem]
9 let assignedIssues: [DashboardItem]
10 let builds: [DashboardBuild]
11
12 enum CodingKeys: String, CodingKey {
13 case pinned, builds
14 case openMRs = "open_mrs"
15 case assignedIssues = "assigned_issues"
16 }
17}
18
19/// One open issue or MR row, repo resolved server-side.
20nonisolated struct DashboardItem: Decodable, Sendable, Hashable, Identifiable {
21 let repo: String
22 let number: Int64
23 let title: String
24 let author: String
25 let state: String
26 let updatedAt: Date
27
28 enum CodingKeys: String, CodingKey {
29 case repo, number, title, author, state
30 case updatedAt = "updated_at"
31 }
32
33 var id: String { "\(repo)#\(number)" }
34}
35
36/// One build row with its repo attached.
37nonisolated struct DashboardBuild: Decodable, Sendable, Hashable, Identifiable {
38 let repo: String
39 let number: Int64
40 let job: String
41 let status: String
42 let sha: String
43 let ref: String
44 let createdAt: Date
45 let finishedAt: Date?
46
47 enum CodingKeys: String, CodingKey {
48 case repo, number, job, status, sha, ref
49 case createdAt = "created_at"
50 case finishedAt = "finished_at"
51 }
52
53 var id: String { "\(repo)#\(number)" }
54}
gitbay/Dashboard/DashboardViewModel.swift +8 −145
@@ -1,164 +1,27 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// "What needs me": open MRs, issues assigned to me, recent builds. 4/// "What needs me": pinned repos, open MRs, assigned issues, recent
5/// 5/// builds — one `dashboard` read. This replaced a per-repo fan-out that
6/// The API has no cross-repo aggregate yet (a recorded gap), so this 6/// drained the rate bucket at 64 of 66 repos; krz/gitbay#41 added the
7/// fans out per repo with bounded concurrency and shows results as they 7/// aggregate.
8/// arrive. ETag revalidation makes the refresh cheap in bytes, but it is
9/// still ~2N requests against a 120/min bucket — the shape that argues
10/// for the one aggregate command; see the filed issue. A 429 stops the
11/// scan and shows what arrived.
12@Observable 8@Observable
13@MainActor 9@MainActor
14final class DashboardViewModel { 10final class DashboardViewModel {
15 11
16 nonisolated struct RepoMR: Sendable, Hashable, Identifiable { 12 private(set) var state: LoadState<DashboardData> = .loading
17 let repo: String
18 let mr: MergeRequest
19 var id: String { "\(repo)!\(mr.number)" }
20 }
21
22 nonisolated struct RepoIssue: Sendable, Hashable, Identifiable {
23 let repo: String
24 let issue: Issue
25 var id: String { "\(repo)#\(issue.number)" }
26 }
27
28 nonisolated struct RepoBuild: Sendable, Hashable, Identifiable {
29 let repo: String
30 let build: Build
31 var id: String { "\(repo)#\(build.number)" }
32 }
33
34 private(set) var openMRs: [RepoMR] = []
35 private(set) var assignedIssues: [RepoIssue] = []
36 private(set) var recentBuilds: [RepoBuild] = []
37 private(set) var scanning = false
38 private(set) var scannedRepos = 0
39 private(set) var totalRepos = 0
40 /// Set when the scan was cut short (rate limit, error) — partial
41 /// results are on screen and honesty about it beats silence.
42 private(set) var scanNote: String?
43 13
44 private let client: GitbayClient 14 private let client: GitbayClient
45 private let username: String
46 /// In-flight cap. Low on purpose: the phone shares one 120/min bucket
47 /// with everything else the user does.
48 private let width = 4
49 15
50 init(client: GitbayClient, username: String) { 16 init(client: GitbayClient) {
51 self.client = client 17 self.client = client
52 self.username = username
53 } 18 }
54 19
55 func load() async { 20 func load() async {
56 scanning = true
57 scanNote = nil
58 defer { scanning = false }
59
60 let repos: [RepoSummary]
61 do { 21 do {
62 repos = try await client.readList(["repo", "list"], of: RepoSummary.self) 22 state = .loaded(try await client.read(["dashboard"], as: DashboardData.self))
63 .filter { !$0.isArchived }
64 } catch { 23 } catch {
65 scanNote = LoadState<Never>.from(error).failureMessage 24 state = .from(error)
66 return
67 }
68 totalRepos = repos.count
69 scannedRepos = 0
70
71 var mrs: [RepoMR] = []
72 var issues: [RepoIssue] = []
73 var builds: [RepoBuild] = []
74
75 // Fan out width-at-a-time; each repo costs up to three reads.
76 var iterator = repos.makeIterator()
77 var stop = false
78 while !stop {
79 var batch: [RepoSummary] = []
80 for _ in 0..<width {
81 if let next = iterator.next() { batch.append(next) }
82 }
83 if batch.isEmpty { break }
84
85 await withTaskGroup(of: RepoScan?.self) { group in
86 for repo in batch {
87 group.addTask { [client, username] in
88 await Self.scan(repo.path, client: client, username: username)
89 }
90 }
91 for await result in group {
92 scannedRepos += 1
93 guard let result else {
94 stop = true
95 continue
96 }
97 mrs.append(contentsOf: result.mrs.map { RepoMR(repo: result.repo, mr: $0) })
98 issues.append(contentsOf: result.issues.map { RepoIssue(repo: result.repo, issue: $0) })
99 builds.append(contentsOf: result.builds.map { RepoBuild(repo: result.repo, build: $0) })
100 }
101 }
102 publish(mrs: mrs, issues: issues, builds: builds)
103 }
104 if stop {
105 scanNote = "Rate limited part way — showing \(scannedRepos) of \(totalRepos) repositories."
106 }
107 }
108
109 private func publish(mrs: [RepoMR], issues: [RepoIssue], builds: [RepoBuild]) {
110 openMRs = mrs.sorted { $0.mr.createdAt > $1.mr.createdAt }
111 assignedIssues = issues.sorted { $0.issue.createdAt > $1.issue.createdAt }
112 recentBuilds = Array(builds.sorted { $0.build.createdAt > $1.build.createdAt }.prefix(10))
113 }
114
115 private nonisolated struct RepoScan: Sendable {
116 let repo: String
117 let mrs: [MergeRequest]
118 let issues: [Issue]
119 let builds: [Build]
120 }
121
122 /// One repo's slice of the dashboard. nil means "stop the scan" —
123 /// the rate limiter said so.
124 private nonisolated static func scan(
125 _ repo: String,
126 client: GitbayClient,
127 username: String
128 ) async -> RepoScan? {
129 do {
130 let mrs = try await client.readList(
131 ["mr", "list", repo, "--state", "open"], of: MergeRequest.self
132 )
133 let issues = try await client.readList(
134 ["issue", "list", repo, "--state", "open"], of: Issue.self
135 ).filter { $0.assignees?.contains(username) == true }
136 // Builds only where something else is happening; a third call
137 // per silent repo is what the missing aggregate would spare.
138 var builds: [Build] = []
139 if !mrs.isEmpty || !issues.isEmpty {
140 builds = Array(try await client.readList(
141 ["build", "list", repo], of: Build.self
142 ).prefix(3))
143 }
144 return RepoScan(repo: repo, mrs: mrs, issues: issues, builds: builds)
145 } catch let error as GitbayError {
146 if case .rateLimited = error { return nil }
147 // One repo failing (permissions changed, whatever) should not
148 // hide the rest of the dashboard.
149 return RepoScan(repo: repo, mrs: [], issues: [], builds: [])
150 } catch {
151 return RepoScan(repo: repo, mrs: [], issues: [], builds: [])
152 }
153 }
154}
155
156extension LoadState {
157 /// The message of a `.failed`/`.empty`, for callers that only need text.
158 var failureMessage: String? {
159 switch self {
160 case .failed(let message), .empty(let message): message
161 default: nil
162 } 25 }
163 } 26 }
164} 27}
gitbay/Issues/IssueListViewModel.swift +21 −18
@@ -1,7 +1,7 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// `issue list <repo> --state <s>`. 4/// `issue list <repo> --state <s>`, paginated.
5@Observable 5@Observable
6@MainActor 6@MainActor
7final class IssueListViewModel { 7final class IssueListViewModel {
@@ -11,30 +11,33 @@ final class IssueListViewModel {
11 var id: String { rawValue } 11 var id: String { rawValue }
12 } 12 }
13 13
14 private(set) var state: LoadState<[Issue]> = .loading 14 let list: PagedListModel<Issue>
15 let repoPath: String
15 var filter: StateFilter = .open { 16 var filter: StateFilter = .open {
16 didSet { if filter != oldValue { Task { await load() } } } 17 didSet {
18 guard filter != oldValue else { return }
19 configureList()
20 Task { await list.reload() }
21 }
17 } 22 }
18 23
19 private let client: GitbayClient
20 let repoPath: String
21
22 init(client: GitbayClient, repoPath: String) { 24 init(client: GitbayClient, repoPath: String) {
23 self.client = client
24 self.repoPath = repoPath 25 self.repoPath = repoPath
26 list = PagedListModel(
27 client: client,
28 argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue],
29 emptyMessage: "No open issues."
30 )
25 } 31 }
26 32
33 var state: LoadState<[Issue]> { list.state }
34
27 func load() async { 35 func load() async {
28 state = .loading 36 await list.reload()
29 do { 37 }
30 let issues = try await client.readList( 38
31 ["issue", "list", repoPath, "--state", filter.rawValue], of: Issue.self 39 private func configureList() {
32 ) 40 list.argv = ["issue", "list", repoPath, "--state", filter.rawValue]
33 state = issues.isEmpty 41 list.emptyMessage = "No \(filter == .all ? "" : filter.rawValue + " ")issues."
34 ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")issues.")
35 : .loaded(issues.sorted { $0.number > $1.number })
36 } catch {
37 state = .from(error)
38 }
39 } 42 }
40} 43}
gitbay/MRs/MRListViewModel.swift +22 −18
@@ -1,7 +1,7 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// `mr list <repo> --state <s>`. 4/// `mr list <repo> --state <s>`, paginated.
5@Observable 5@Observable
6@MainActor 6@MainActor
7final class MRListViewModel { 7final class MRListViewModel {
@@ -11,30 +11,34 @@ final class MRListViewModel {
11 var id: String { rawValue } 11 var id: String { rawValue }
12 } 12 }
13 13
14 private(set) var state: LoadState<[MergeRequest]> = .loading 14 let list: PagedListModel<MergeRequest>
15 let repoPath: String
15 var filter: StateFilter = .open { 16 var filter: StateFilter = .open {
16 didSet { if filter != oldValue { Task { await load() } } } 17 didSet {
18 guard filter != oldValue else { return }
19 configureList()
20 Task { await list.reload() }
21 }
17 } 22 }
18 23
19 private let client: GitbayClient
20 let repoPath: String
21
22 init(client: GitbayClient, repoPath: String) { 24 init(client: GitbayClient, repoPath: String) {
23 self.client = client
24 self.repoPath = repoPath 25 self.repoPath = repoPath
26 list = PagedListModel(
27 client: client,
28 argv: ["mr", "list", repoPath, "--state", StateFilter.open.rawValue],
29 emptyMessage: "No open merge requests."
30 )
25 } 31 }
26 32
33 var state: LoadState<[MergeRequest]> { list.state }
34
27 func load() async { 35 func load() async {
28 state = .loading 36 await list.reload()
29 do { 37 }
30 let mrs = try await client.readList( 38
31 ["mr", "list", repoPath, "--state", filter.rawValue], of: MergeRequest.self 39 private func configureList() {
32 ) 40 list.argv = ["mr", "list", repoPath, "--state", filter.rawValue]
33 state = mrs.isEmpty 41 list.emptyMessage =
34 ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")merge requests.") 42 "No \(filter == .all ? "" : filter.rawValue + " ")merge requests."
35 : .loaded(mrs.sorted { $0.number > $1.number })
36 } catch {
37 state = .from(error)
38 }
39 } 43 }
40} 44}
gitbay/Networking/GitbayClient.swift +26
@@ -79,6 +79,32 @@ nonisolated final class GitbayClient: Sendable {
79 return envelope.data ?? [] 79 return envelope.data ?? []
80 } 80 }
81 81
82 /// One page of a paginated list command. With `--limit`/`--cursor`
83 /// present the server moves the array under `items` and returns the
84 /// opaque `next` cursor alongside; `next` is absent on the last page.
85 nonisolated struct Page<Element: Decodable & Sendable>: Decodable, Sendable {
86 let items: [Element]
87 let next: String?
88 }
89
90 /// Run a paginated list command (`repo list`, `issue list`, `mr
91 /// list`, `feed`). Cursors are opaque and kind-checked server-side —
92 /// pass back exactly what `next` carried, never synthesize one.
93 func readPage<Element: Decodable & Sendable>(
94 _ argv: [String],
95 of _: Element.Type,
96 limit: Int,
97 cursor: String? = nil
98 ) async throws -> Page<Element> {
99 var argv = argv + ["--limit", String(limit)]
100 if let cursor { argv.append(contentsOf: ["--cursor", cursor]) }
101 let envelope: Envelope<Page<Element>> = try await readEnvelope(argv)
102 guard let page = envelope.data else {
103 throw GitbayError.decoding(MissingData(argv: argv))
104 }
105 return page
106 }
107
82 /// Run a read-only command that emits raw text rather than JSON 108 /// Run a read-only command that emits raw text rather than JSON
83 /// (`mr diff`, `build log`). The server wraps those as `output`. 109 /// (`mr diff`, `build log`). The server wraps those as `output`.
84 func readText(_ argv: [String]) async throws -> String { 110 func readText(_ argv: [String]) async throws -> String {
gitbay/Repos/PagedListModel.swift added +68
@@ -0,0 +1,68 @@
1import Foundation
2import Observation
3
4/// Accumulated pages of one list command (`repo list`, `issue list`,
5/// `mr list`). Cursors are opaque and server-minted; the model only
6/// hands back what `next` carried.
7@Observable
8@MainActor
9final class PagedListModel<Element: Decodable & Sendable> {
10
11 private(set) var state: LoadState<[Element]> = .loading
12 private(set) var isLoadingMore = false
13 private var next: String?
14
15 var hasMore: Bool { next != nil }
16
17 private let client: GitbayClient
18 private let pageSize: Int
19 /// The list command minus paging flags. Set by the owning view model
20 /// when a filter changes; a change only takes effect via `reload()`.
21 var argv: [String]
22 var emptyMessage: String
23
24 init(
25 client: GitbayClient,
26 argv: [String],
27 emptyMessage: String,
28 pageSize: Int = 50
29 ) {
30 self.client = client
31 self.argv = argv
32 self.emptyMessage = emptyMessage
33 self.pageSize = pageSize
34 }
35
36 func reload() async {
37 state = .loading
38 next = nil
39 await fetch(cursor: nil, appendingTo: [])
40 }
41
42 /// Fetch the next page. Safe to call repeatedly from row-appear
43 /// triggers; it no-ops while a fetch is in flight or at the end.
44 func loadMore() async {
45 guard let cursor = next, !isLoadingMore, let loaded = state.value else { return }
46 isLoadingMore = true
47 defer { isLoadingMore = false }
48 await fetch(cursor: cursor, appendingTo: loaded)
49 }
50
51 private func fetch(cursor: String?, appendingTo existing: [Element]) async {
52 do {
53 let page = try await client.readPage(
54 argv, of: Element.self, limit: pageSize, cursor: cursor
55 )
56 next = page.next
57 let all = existing + page.items
58 state = all.isEmpty ? .empty(emptyMessage) : .loaded(all)
59 } catch {
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
62 // retry from the same trigger.
63 if existing.isEmpty {
64 state = .from(error)
65 }
66 }
67 }
68}
gitbay/Repos/RepoListViewModel.swift +14 −14
@@ -1,22 +1,29 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// The `repo list` screen: everything you own or can reach, filterable. 4/// The `repo list` screen, paginated, filterable client-side. The filter
5/// only sees loaded pages; the page size is large enough that one page
6/// covers most accounts today.
5@Observable 7@Observable
6@MainActor 8@MainActor
7final class RepoListViewModel { 9final class RepoListViewModel {
8 10
9 private(set) var state: LoadState<[RepoSummary]> = .loading 11 let list: PagedListModel<RepoSummary>
10 var searchText = "" 12 var searchText = ""
11 13
12 private let client: GitbayClient
13
14 init(client: GitbayClient) { 14 init(client: GitbayClient) {
15 self.client = client 15 list = PagedListModel(
16 client: client,
17 argv: ["repo", "list"],
18 emptyMessage: "No repositories yet. Create one over SSH: gitbay repo create <name>",
19 pageSize: 100
20 )
16 } 21 }
17 22
23 var state: LoadState<[RepoSummary]> { list.state }
24
18 var visibleRepos: [RepoSummary] { 25 var visibleRepos: [RepoSummary] {
19 guard let repos = state.value else { return [] } 26 guard let repos = list.state.value else { return [] }
20 let query = searchText.trimmingCharacters(in: .whitespaces).lowercased() 27 let query = searchText.trimmingCharacters(in: .whitespaces).lowercased()
21 guard !query.isEmpty else { return repos } 28 guard !query.isEmpty else { return repos }
22 return repos.filter { 29 return repos.filter {
@@ -26,13 +33,6 @@ final class RepoListViewModel {
26 } 33 }
27 34
28 func load() async { 35 func load() async {
29 do { 36 await list.reload()
30 let repos = try await client.readList(["repo", "list"], of: RepoSummary.self)
31 state = repos.isEmpty
32 ? .empty("No repositories yet. Create one over SSH: gitbay repo create <name>")
33 : .loaded(repos.sorted { $0.path < $1.path })
34 } catch {
35 state = .from(error)
36 }
37 } 37 }
38} 38}
gitbay/Views/Dashboard/DashboardView.swift +115 −75
@@ -1,114 +1,154 @@
1import SwiftUI 1import SwiftUI
2 2
3/// What needs me, across every repo I can reach. 3/// What needs me, in one read.
4struct DashboardView: View { 4struct DashboardView: View {
5 5
6 @State private var model: DashboardViewModel 6 @State private var model: DashboardViewModel
7 7
8 init(client: GitbayClient, username: String) { 8 init(client: GitbayClient) {
9 _model = State(initialValue: DashboardViewModel(client: client, username: username)) 9 _model = State(initialValue: DashboardViewModel(client: client))
10 } 10 }
11 11
12 var body: some View { 12 var body: some View {
13 List { 13 List {
14 if let note = model.scanNote { 14 if let data = model.state.value {
15 Section { 15 if !data.pinned.isEmpty {
16 Label(note, systemImage: "exclamationmark.triangle") 16 pinnedSection(data.pinned)
17 .font(.caption)
18 .foregroundStyle(.orange)
19 } 17 }
18 itemSection(
19 "Needs review", items: data.openMRs,
20 empty: "No open merge requests.",
21 marker: "!"
22 ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
23 itemSection(
24 "Assigned to me", items: data.assignedIssues,
25 empty: "No assigned issues.",
26 marker: "#"
27 ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
28 buildsSection(data.builds)
20 } 29 }
30 }
31 .overlay { LoadStateOverlay(state: model.state) }
32 .navigationTitle("Dashboard")
33 .toolbar { AccountMenu() }
34 .task { await model.load() }
35 .refreshable { await model.load() }
36 }
21 37
22 Section("Needs review") { 38 private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
23 if model.openMRs.isEmpty { 39 Section("Pinned") {
24 emptyRow(model.scanning ? "Scanning…" : "No open merge requests.") 40 ForEach(pinned) { repo in
25 } else { 41 NavigationLink(value: RepoRoute.repo(repo.path)) {
26 ForEach(model.openMRs) { entry in 42 VStack(alignment: .leading, spacing: 2) {
27 NavigationLink(value: MRRoute.mr(repo: entry.repo, number: entry.mr.number)) { 43 HStack(spacing: 6) {
28 VStack(alignment: .leading, spacing: 2) { 44 Text(repo.path)
29 Text(entry.repo) 45 .font(.subheadline.weight(.medium))
30 .font(.caption) 46 .lineLimit(1)
47 if repo.isPrivate {
48 Image(systemName: "lock.fill")
49 .font(.caption2)
31 .foregroundStyle(.secondary) 50 .foregroundStyle(.secondary)
32 MRRow(mr: entry.mr)
33 } 51 }
34 } 52 }
53 if let description = repo.description, !description.isEmpty {
54 Text(description)
55 .font(.caption)
56 .foregroundStyle(.secondary)
57 .lineLimit(1)
58 }
35 } 59 }
36 } 60 }
37 } 61 }
62 }
63 }
38 64
39 Section("Assigned to me") { 65 private func itemSection(
40 if model.assignedIssues.isEmpty { 66 _ title: String,
41 emptyRow(model.scanning ? "Scanning…" : "No assigned issues.") 67 items: [DashboardItem],
42 } else { 68 empty: String,
43 ForEach(model.assignedIssues) { entry in 69 marker: String,
44 NavigationLink(value: IssueRoute.issue(repo: entry.repo, number: entry.issue.number)) { 70 route: @escaping (DashboardItem) -> some Hashable
45 VStack(alignment: .leading, spacing: 2) { 71 ) -> some View {
46 Text(entry.repo) 72 Section(title) {
47 .font(.caption) 73 if items.isEmpty {
74 Text(empty)
75 .font(.subheadline)
76 .foregroundStyle(.secondary)
77 } else {
78 ForEach(items) { item in
79 NavigationLink(value: route(item)) {
80 VStack(alignment: .leading, spacing: 2) {
81 Text(item.repo)
82 .font(.caption)
83 .foregroundStyle(.secondary)
84 HStack(alignment: .firstTextBaseline, spacing: 6) {
85 Text(marker + String(item.number))
86 .font(.caption.monospaced())
48 .foregroundStyle(.secondary) 87 .foregroundStyle(.secondary)
49 HStack(spacing: 6) { 88 Text(item.title)
50 Text("#\(entry.issue.number)") 89 .font(.subheadline.weight(.medium))
51 .font(.caption.monospaced()) 90 .lineLimit(2)
52 .foregroundStyle(.secondary)
53 Text(entry.issue.title)
54 .font(.subheadline.weight(.medium))
55 .lineLimit(2)
56 }
57 } 91 }
92 HStack(spacing: 6) {
93 Text("by \(item.author)")
94 Text(item.updatedAt, format: .relative(presentation: .named))
95 .foregroundStyle(.tertiary)
96 }
97 .font(.caption)
98 .foregroundStyle(.secondary)
58 } 99 }
59 } 100 }
60 } 101 }
61 } 102 }
103 }
104 }
62 105
63 Section("Recent builds") { 106 private func buildsSection(_ builds: [DashboardBuild]) -> some View {
64 if model.recentBuilds.isEmpty { 107 Section("Recent builds") {
65 emptyRow(model.scanning ? "Scanning…" : "No recent builds where something is open.") 108 if builds.isEmpty {
66 } else { 109 Text("No recent builds.")
67 ForEach(model.recentBuilds) { entry in 110 .font(.subheadline)
68 NavigationLink(value: BuildRoute.log(repo: entry.repo, number: entry.build.number)) { 111 .foregroundStyle(.secondary)
69 HStack(spacing: 8) { 112 } else {
70 Image(systemName: entry.build.status == "success" 113 ForEach(builds) { build in
71 ? "checkmark.circle.fill" 114 NavigationLink(value: BuildRoute.log(repo: build.repo, number: build.number)) {
72 : entry.build.status == "failure" ? "xmark.circle.fill" : "circle.dotted") 115 HStack(spacing: 8) {
73 .foregroundStyle(entry.build.status == "success" 116 Image(systemName: buildIcon(build.status))
74 ? .green : entry.build.status == "failure" ? .red : .orange) 117 .foregroundStyle(buildColor(build.status))
75 VStack(alignment: .leading, spacing: 2) { 118 VStack(alignment: .leading, spacing: 2) {
76 Text(entry.repo) 119 Text(build.repo)
77 .font(.caption)
78 .foregroundStyle(.secondary)
79 Text("#\(entry.build.number) \(entry.build.job)")
80 .font(.subheadline)
81 }
82 Spacer()
83 Text(entry.build.createdAt, format: .relative(presentation: .named))
84 .font(.caption) 120 .font(.caption)
85 .foregroundStyle(.tertiary) 121 .foregroundStyle(.secondary)
122 Text("#\(build.number) \(build.job)")
123 .font(.subheadline)
86 } 124 }
125 Spacer()
126 Text(build.createdAt, format: .relative(presentation: .named))
127 .font(.caption)
128 .foregroundStyle(.tertiary)
87 } 129 }
88 } 130 }
89 } 131 }
90 } 132 }
133 }
134 }
91 135
92 if model.scanning { 136 private func buildIcon(_ status: String) -> String {
93 Section { 137 switch status {
94 HStack { 138 case "success": "checkmark.circle.fill"
95 ProgressView() 139 case "failure", "error": "xmark.circle.fill"
96 Text("Scanning \(model.scannedRepos)/\(model.totalRepos) repositories…") 140 case "running": "circle.dotted"
97 .font(.caption) 141 case "queued", "pending": "clock"
98 .foregroundStyle(.secondary) 142 default: "questionmark.circle"
99 }
100 }
101 }
102 } 143 }
103 .navigationTitle("Dashboard")
104 .toolbar { AccountMenu() }
105 .task { await model.load() }
106 .refreshable { await model.load() }
107 } 144 }
108 145
109 private func emptyRow(_ text: String) -> some View { 146 private func buildColor(_ status: String) -> Color {
110 Text(text) 147 switch status {
111 .font(.subheadline) 148 case "success": .green
112 .foregroundStyle(.secondary) 149 case "failure", "error": .red
150 case "running", "queued", "pending": .orange
151 default: .secondary
152 }
113 } 153 }
114} 154}
gitbay/Views/Issues/IssueListView.swift +1
@@ -24,6 +24,7 @@ struct IssueListView: View {
24 IssueRow(issue: issue) 24 IssueRow(issue: issue)
25 } 25 }
26 } 26 }
27 PageFooter(list: model.list)
27 } 28 }
28 .overlay { LoadStateOverlay(state: model.state) } 29 .overlay { LoadStateOverlay(state: model.state) }
29 .navigationTitle("Issues") 30 .navigationTitle("Issues")
gitbay/Views/MRs/MRListView.swift +1
@@ -24,6 +24,7 @@ struct MRListView: View {
24 MRRow(mr: mr) 24 MRRow(mr: mr)
25 } 25 }
26 } 26 }
27 PageFooter(list: model.list)
27 } 28 }
28 .overlay { LoadStateOverlay(state: model.state) } 29 .overlay { LoadStateOverlay(state: model.state) }
29 .navigationTitle("Merge Requests") 30 .navigationTitle("Merge Requests")
gitbay/Views/Repos/PageFooter.swift added +21
@@ -0,0 +1,21 @@
1import SwiftUI
2
3/// The last row of a paginated list: appears, fetches the next page,
4/// re-arms after each append until the server stops sending a cursor.
5struct PageFooter<Element: Decodable & Sendable>: View {
6
7 let list: PagedListModel<Element>
8
9 var body: some View {
10 if list.hasMore {
11 HStack {
12 Spacer()
13 ProgressView()
14 Spacer()
15 }
16 .task(id: list.state.value?.count ?? 0) {
17 await list.loadMore()
18 }
19 }
20 }
21}
gitbay/Views/Repos/RepoListView.swift +3
@@ -16,6 +16,9 @@ struct RepoListView: View {
16 RepoRow(repo: repo) 16 RepoRow(repo: repo)
17 } 17 }
18 } 18 }
19 if model.searchText.isEmpty {
20 PageFooter(list: model.list)
21 }
19 } 22 }
20 .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) } 23 .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) }
21 .searchable(text: Bindable(model).searchText, prompt: "Filter repositories") 24 .searchable(text: Bindable(model).searchText, prompt: "Filter repositories")
gitbayTests/DashboardViewModelTests.swift +46 −69
@@ -12,92 +12,69 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
12 return (client, box) 12 return (client, box)
13} 13}
14 14
15private let twoRepoList = """ 15private let dashboardJSON = """
16 {"protocol_version":1,"data":[\ 16 {"protocol_version":1,"data":{\
17 {"path":"krz/gitbay","visibility":"public"},\ 17 "pinned":[{"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"}],\
18 {"path":"krz/dust","visibility":"public","archived":true},\ 18 "open_mrs":[{"repo":"krz/gitbay-ios","number":3,"title":"adopt aggregate","author":"cmc",\
19 {"path":"krz/hutch","visibility":"public"}\ 19 "state":"open","updated_at":"2026-08-27T10:00:00.000Z"}],\
20 ],"exit_code":0} 20 "assigned_issues":[{"repo":"krz/gitbay","number":11,"title":"iOS app","author":"krz",\
21 "state":"open","updated_at":"2026-08-26T10:00:00.000Z"}],\
22 "builds":[{"repo":"krz/gitbay","number":9,"job":"ci","status":"success",\
23 "sha":"65ba14e0000000000000","ref":"refs/heads/main",\
24 "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}]},\
25 "exit_code":0}
21 """ 26 """
22 27
23private let openMRJSON = """ 28private let emptyDashboardJSON = """
24 {"protocol_version":1,"data":[\ 29 {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\
25 {"number":7,"title":"fix","state":"open","author":"krz","source":"fix",\ 30 "exit_code":0}
26 "target_ref":"main","head_sha":"aa","created_at":"2026-08-22T10:00:00.000Z"}\
27 ],"exit_code":0}
28 """ 31 """
29 32
30private let assignedIssueJSON = """ 33@MainActor
31 {"protocol_version":1,"data":[\ 34struct DashboardViewModelTests {
32 {"number":3,"title":"mine","state":"open","author":"krz","assignees":["cmc"],\
33 "created_at":"2026-08-23T10:00:00.000Z"},\
34 {"number":4,"title":"theirs","state":"open","author":"krz","assignees":["krz"],\
35 "created_at":"2026-08-23T11:00:00.000Z"}\
36 ],"exit_code":0}
37 """
38 35
39private let emptyJSON = #"{"protocol_version":1,"exit_code":0}"# 36 @Test func oneReadFillsEverySection() async throws {
37 let (client, stub) = try makeClient()
38 stub.enqueue(.init(status: 200, json: dashboardJSON))
39 let model = DashboardViewModel(client: client)
40 40
41private let buildJSON = """ 41 await model.load()
42 {"protocol_version":1,"data":[\
43 {"number":9,"job":"ci","status":"success","sha":"aa00000000",\
44 "ref":"refs/heads/main","created_at":"2026-08-22T10:05:00.000Z"}\
45 ],"exit_code":0}
46 """
47 42
48@MainActor 43 let data = try #require(model.state.value)
49struct DashboardViewModelTests { 44 #expect(data.pinned.map(\.path) == ["krz/gitbay"])
45 #expect(data.openMRs.map(\.id) == ["krz/gitbay-ios#3"])
46 #expect(data.assignedIssues.first?.number == 11)
47 #expect(data.builds.first?.status == "success")
48 // The whole screen cost exactly one request.
49 #expect(stub.seen.count == 1)
50 #expect(stub.seen.first?.url.query() == "argv=dashboard")
51 }
50 52
51 @Test func aggregatesAcrossReposSkippingArchivedOnes() async throws { 53 @Test func emptyAggregateStillLoads() async throws {
52 let (client, stub) = try makeClient() 54 let (client, stub) = try makeClient()
53 stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list")) 55 stub.enqueue(.init(status: 200, json: emptyDashboardJSON))
54 // krz/gitbay: one open MR, one assigned + one unassigned issue, a build. 56 let model = DashboardViewModel(client: client)
55 stub.enqueue(.init(status: 200, json: openMRJSON, match: "argv=mr&argv=list&argv=krz/gitbay"))
56 stub.enqueue(.init(status: 200, json: assignedIssueJSON, match: "argv=issue&argv=list&argv=krz/gitbay"))
57 stub.enqueue(.init(status: 200, json: buildJSON, match: "argv=build&argv=list&argv=krz/gitbay"))
58 // krz/hutch: nothing open — no build call should follow.
59 stub.enqueue(.init(status: 200, json: emptyJSON, match: "argv=mr&argv=list&argv=krz/hutch"))
60 stub.enqueue(.init(status: 200, json: emptyJSON, match: "argv=issue&argv=list&argv=krz/hutch"))
61 let model = DashboardViewModel(client: client, username: "cmc")
62 57
63 await model.load() 58 await model.load()
64 59
65 #expect(model.openMRs.map(\.id) == ["krz/gitbay!7"]) 60 let data = try #require(model.state.value)
66 // Only issues assigned to me, and never from the archived repo. 61 #expect(data.pinned.isEmpty)
67 #expect(model.assignedIssues.map(\.id) == ["krz/gitbay#3"]) 62 #expect(data.openMRs.isEmpty)
68 #expect(model.recentBuilds.map(\.id) == ["krz/gitbay#9"])
69 #expect(model.scanNote == nil)
70 // The archived repo cost zero requests; the quiet repo cost two.
71 let scanned = stub.seen.map { $0.url.query() ?? "" }
72 #expect(!scanned.contains { $0.contains("krz/dust") })
73 #expect(scanned.count { $0.contains("krz/hutch") } == 2)
74 } 63 }
75 64
76 @Test func rateLimitStopsTheScanAndSaysSo() async throws { 65 @Test func aFailureIsTheScreensState() async throws {
77 let (client, stub) = try makeClient() 66 let (client, stub) = try makeClient()
78 stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list")) 67 stub.enqueue(.init(status: 500, json:
79 stub.enqueue(.init(status: 200, json: openMRJSON, match: "argv=mr&argv=list&argv=krz/gitbay")) 68 #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
80 stub.enqueue(.init(status: 200, json: assignedIssueJSON, match: "argv=issue&argv=list&argv=krz/gitbay")) 69 stub.enqueue(.init(status: 500, json:
81 stub.enqueue(.init(status: 200, json: buildJSON, match: "argv=build&argv=list&argv=krz/gitbay")) 70 #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
82 stub.enqueue(.init( 71 let model = DashboardViewModel(client: client)
83 status: 429,
84 headers: ["Retry-After": "30"],
85 json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#,
86 match: "krz/hutch"
87 ))
88 stub.enqueue(.init(
89 status: 429,
90 headers: ["Retry-After": "30"],
91 json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#,
92 match: "krz/hutch"
93 ))
94 let model = DashboardViewModel(client: client, username: "cmc")
95 72
96 await model.load() 73 await model.load()
97 74
98 // What arrived before the limit is still on screen. 75 guard case .failed = model.state else {
99 #expect(model.openMRs.count == 1) 76 Issue.record("expected .failed, got \(model.state)")
100 let note = try #require(model.scanNote) 77 return
101 #expect(note.contains("Rate limited")) 78 }
102 } 79 }
103} 80}
gitbayTests/IssueBuildViewModelTests.swift +7 −7
@@ -13,12 +13,12 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
13} 13}
14 14
15private let issueListJSON = """ 15private let issueListJSON = """
16 {"protocol_version":1,"data":[\ 16 {"protocol_version":1,"data":{"items":[\
17 {"number":11,"title":"iOS app","state":"open","author":"krz",\ 17 {"number":11,"title":"iOS app","state":"open","author":"krz",\
18 "labels":["app"],"assignees":["cmc"],"created_at":"2026-08-20T10:00:00.000Z"},\ 18 "labels":["app"],"assignees":["cmc"],"created_at":"2026-08-20T10:00:00.000Z"},\
19 {"number":40,"title":"pagination","state":"open","author":"krz",\ 19 {"number":40,"title":"pagination","state":"open","author":"krz",\
20 "created_at":"2026-08-21T10:00:00.000Z"}\ 20 "created_at":"2026-08-21T10:00:00.000Z"}\
21 ],"exit_code":0} 21 ]},"exit_code":0}
22 """ 22 """
23 23
24private let issueShowJSON = """ 24private let issueShowJSON = """
@@ -42,7 +42,7 @@ private let buildListJSON = """
42@MainActor 42@MainActor
43struct IssueListViewModelTests { 43struct IssueListViewModelTests {
44 44
45 @Test func listsNewestFirstWithLabelsAndAssignees() async throws { 45 @Test func listsInServerOrderWithLabelsAndAssignees() async throws {
46 let (client, stub) = try makeClient() 46 let (client, stub) = try makeClient()
47 stub.enqueue(.init(status: 200, json: issueListJSON)) 47 stub.enqueue(.init(status: 200, json: issueListJSON))
48 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") 48 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
@@ -50,11 +50,11 @@ struct IssueListViewModelTests {
50 await model.load() 50 await model.load()
51 51
52 let issues = try #require(model.state.value) 52 let issues = try #require(model.state.value)
53 #expect(issues.map(\.number) == [40, 11]) 53 #expect(issues.map(\.number) == [11, 40])
54 #expect(issues[1].labels == ["app"]) 54 #expect(issues[0].labels == ["app"])
55 #expect(issues[1].assignees == ["cmc"]) 55 #expect(issues[0].assignees == ["cmc"])
56 #expect(stub.seen.first?.url.query() == 56 #expect(stub.seen.first?.url.query() ==
57 "argv=issue&argv=list&argv=krz/gitbay&argv=--state&argv=open") 57 "argv=issue&argv=list&argv=krz/gitbay&argv=--state&argv=open&argv=--limit&argv=50")
58 } 58 }
59} 59}
60 60
gitbayTests/MRViewModelTests.swift +6 −6
@@ -13,14 +13,14 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
13} 13}
14 14
15private let mrListJSON = """ 15private let mrListJSON = """
16 {"protocol_version":1,"data":[\ 16 {"protocol_version":1,"data":{"items":[\
17 {"number":7,"title":"client: envelope decoding","state":"open","author":"cmc",\ 17 {"number":7,"title":"client: envelope decoding","state":"open","author":"cmc",\
18 "source":"client-envelope","target_ref":"main","head_sha":"aabbcc",\ 18 "source":"client-envelope","target_ref":"main","head_sha":"aabbcc",\
19 "created_at":"2026-08-20T10:00:00.000Z"},\ 19 "created_at":"2026-08-20T10:00:00.000Z"},\
20 {"number":9,"title":"auth: keychain","state":"open","author":"cmc",\ 20 {"number":9,"title":"auth: keychain","state":"open","author":"cmc",\
21 "source":"krz/fork:auth","target_ref":"main","head_sha":"ddeeff",\ 21 "source":"krz/fork:auth","target_ref":"main","head_sha":"ddeeff",\
22 "created_at":"2026-08-21T10:00:00.000Z"}\ 22 "created_at":"2026-08-21T10:00:00.000Z"}\
23 ],"exit_code":0} 23 ]},"exit_code":0}
24 """ 24 """
25 25
26private let mrShowJSON = """ 26private let mrShowJSON = """
@@ -140,7 +140,7 @@ struct UnifiedDiffParserTests {
140@MainActor 140@MainActor
141struct MRListViewModelTests { 141struct MRListViewModelTests {
142 142
143 @Test func listsNewestFirst() async throws { 143 @Test func listsInServerOrderWithPagingFlags() async throws {
144 let (client, stub) = try makeClient() 144 let (client, stub) = try makeClient()
145 stub.enqueue(.init(status: 200, json: mrListJSON)) 145 stub.enqueue(.init(status: 200, json: mrListJSON))
146 let model = MRListViewModel(client: client, repoPath: "krz/gitbay") 146 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
@@ -148,16 +148,16 @@ struct MRListViewModelTests {
148 await model.load() 148 await model.load()
149 149
150 let mrs = try #require(model.state.value) 150 let mrs = try #require(model.state.value)
151 #expect(mrs.map(\.number) == [9, 7]) 151 #expect(mrs.map(\.number) == [7, 9])
152 let seen = try #require(stub.seen.first) 152 let seen = try #require(stub.seen.first)
153 #expect(seen.url.query() == 153 #expect(seen.url.query() ==
154 "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open") 154 "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open&argv=--limit&argv=50")
155 } 155 }
156 156
157 @Test func changingTheFilterReloadsWithThatState() async throws { 157 @Test func changingTheFilterReloadsWithThatState() async throws {
158 let (client, stub) = try makeClient() 158 let (client, stub) = try makeClient()
159 stub.enqueue(.init(status: 200, json: mrListJSON)) 159 stub.enqueue(.init(status: 200, json: mrListJSON))
160 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#)) 160 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
161 let model = MRListViewModel(client: client, repoPath: "krz/gitbay") 161 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
162 await model.load() 162 await model.load()
163 163
gitbayTests/PagedListTests.swift added +125
@@ -0,0 +1,125 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private nonisolated struct Row: Decodable, Sendable, Equatable {
16 let number: Int
17}
18
19private func page(_ numbers: [Int], next: String? = nil) -> String {
20 let items = numbers.map { #"{"number":\#($0)}"# }.joined(separator: ",")
21 let nextField = next.map { #","next":"\#($0)""# } ?? ""
22 return #"{"protocol_version":1,"data":{"items":[\#(items)]\#(nextField)},"exit_code":0}"#
23}
24
25struct ClientPagingTests {
26
27 @Test func readPageSendsLimitAndCursorAndDecodesNext() async throws {
28 let (client, stub) = try makeClient()
29 stub.enqueue(.init(status: 200, json: page([1, 2], next: "abc")))
30 stub.enqueue(.init(status: 200, json: page([3])))
31
32 let first = try await client.readPage(["issue", "list", "krz/gitbay"], of: Row.self, limit: 2)
33 let second = try await client.readPage(
34 ["issue", "list", "krz/gitbay"], of: Row.self, limit: 2, cursor: first.next
35 )
36
37 #expect(first.items == [Row(number: 1), Row(number: 2)])
38 #expect(first.next == "abc")
39 #expect(second.items == [Row(number: 3)])
40 #expect(second.next == nil)
41 #expect(stub.seen[0].url.query() ==
42 "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2")
43 #expect(stub.seen[1].url.query() ==
44 "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2&argv=--cursor&argv=abc")
45 }
46}
47
48@MainActor
49struct PagedListModelTests {
50
51 private func makeList(pageSize: Int = 2) throws -> (PagedListModel<Row>, StubProtocol.Box) {
52 let (client, stub) = try makeClient()
53 let list = PagedListModel<Row>(
54 client: client,
55 argv: ["issue", "list", "krz/gitbay"],
56 emptyMessage: "nothing",
57 pageSize: pageSize
58 )
59 return (list, stub)
60 }
61
62 @Test func loadMoreAppendsUntilTheCursorRunsOut() async throws {
63 let (list, stub) = try makeList()
64 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
65 stub.enqueue(.init(status: 200, json: page([3, 4], next: "c2")))
66 stub.enqueue(.init(status: 200, json: page([5])))
67
68 await list.reload()
69 #expect(list.state.value == [Row(number: 1), Row(number: 2)])
70 #expect(list.hasMore)
71
72 await list.loadMore()
73 await list.loadMore()
74 #expect(list.state.value?.map(\.number) == [1, 2, 3, 4, 5])
75 #expect(!list.hasMore)
76
77 // Further calls are no-ops, not requests.
78 await list.loadMore()
79 #expect(stub.seen.count == 3)
80 #expect(stub.seen[1].url.query()?.contains("argv=--cursor&argv=c1") == true)
81 #expect(stub.seen[2].url.query()?.contains("argv=--cursor&argv=c2") == true)
82 }
83
84 @Test func anEmptyFirstPageIsAnEmptyState() async throws {
85 let (list, stub) = try makeList()
86 stub.enqueue(.init(status: 200, json: page([])))
87
88 await list.reload()
89
90 guard case .empty(let message) = list.state else {
91 Issue.record("expected .empty, got \(list.state)")
92 return
93 }
94 #expect(message == "nothing")
95 }
96
97 @Test func aFailedLaterPageKeepsTheListAndTheCursor() async throws {
98 let (list, stub) = try makeList()
99 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
100 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
101 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
102
103 await list.reload()
104 await list.loadMore()
105
106 #expect(list.state.value == [Row(number: 1), Row(number: 2)])
107 #expect(list.hasMore) // the cursor survives for a retry
108
109 stub.enqueue(.init(status: 200, json: page([3])))
110 await list.loadMore()
111 #expect(list.state.value?.map(\.number) == [1, 2, 3])
112 }
113
114 @Test func reloadDropsTheOldCursorAndItems() async throws {
115 let (list, stub) = try makeList()
116 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
117 stub.enqueue(.init(status: 200, json: page([9])))
118
119 await list.reload()
120 await list.reload()
121
122 #expect(list.state.value == [Row(number: 9)])
123 #expect(!list.hasMore)
124 }
125}
gitbayTests/RepoViewModelTests.swift +7 −5
@@ -13,11 +13,11 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
13} 13}
14 14
15private let repoListJSON = """ 15private let repoListJSON = """
16 {"protocol_version":1,"data":[\ 16 {"protocol_version":1,"data":{"items":[\
17 {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\
18 {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\ 17 {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\
18 {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\
19 {"path":"krz/secrets","visibility":"private","archived":true}\ 19 {"path":"krz/secrets","visibility":"private","archived":true}\
20 ],"exit_code":0} 20 ]},"exit_code":0}
21 """ 21 """
22 22
23private let repoShowJSON = """ 23private let repoShowJSON = """
@@ -61,7 +61,7 @@ private let logJSON = """
61@MainActor 61@MainActor
62struct RepoListViewModelTests { 62struct RepoListViewModelTests {
63 63
64 @Test func loadsAndSortsByPath() async throws { 64 @Test func loadsOnePageInServerOrder() async throws {
65 let (client, stub) = try makeClient() 65 let (client, stub) = try makeClient()
66 stub.enqueue(.init(status: 200, json: repoListJSON)) 66 stub.enqueue(.init(status: 200, json: repoListJSON))
67 let model = RepoListViewModel(client: client) 67 let model = RepoListViewModel(client: client)
@@ -71,6 +71,8 @@ struct RepoListViewModelTests {
71 #expect(model.visibleRepos.map(\.path) == ["krz/gitbay", "krz/hutch", "krz/secrets"]) 71 #expect(model.visibleRepos.map(\.path) == ["krz/gitbay", "krz/hutch", "krz/secrets"])
72 #expect(model.visibleRepos[2].isPrivate) 72 #expect(model.visibleRepos[2].isPrivate)
73 #expect(model.visibleRepos[2].isArchived) 73 #expect(model.visibleRepos[2].isArchived)
74 let seen = try #require(stub.seen.first)
75 #expect(seen.url.query() == "argv=repo&argv=list&argv=--limit&argv=100")
74 } 76 }
75 77
76 @Test func filterMatchesPathAndDescription() async throws { 78 @Test func filterMatchesPathAndDescription() async throws {
@@ -88,7 +90,7 @@ struct RepoListViewModelTests {
88 90
89 @Test func anEmptyListIsAnEmptyStateNotAnError() async throws { 91 @Test func anEmptyListIsAnEmptyStateNotAnError() async throws {
90 let (client, stub) = try makeClient() 92 let (client, stub) = try makeClient()
91 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#)) 93 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
92 let model = RepoListViewModel(client: client) 94 let model = RepoListViewModel(client: client)
93 95
94 await model.load() 96 await model.load()