a native ios client for gitbay

client ios swift

https://gitbay.org

adopt the dashboard aggregate and cursor pagination !3

merged cmc wants to merge krz/gitbay-ios:adopt-dashboard-pagination into main

18 files changed, +546 −358

gitbay/ContentView.swift +1 −1
@@ -9,7 +9,7 @@ struct ContentView: View {
99 TabView {
1010 Tab("Dashboard", systemImage: "square.grid.2x2") {
1111 NavigationStack {
12 DashboardView(client: client, username: account.username)
12 DashboardView(client: client)
1313 .navigationDestinations(client: client)
1414 }
1515 }
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 @@
11 import Foundation
22 import Observation
33
4/// "What needs me": open MRs, issues assigned to me, recent builds.
5///
6/// The API has no cross-repo aggregate yet (a recorded gap), so this
7/// fans out per repo with bounded concurrency and shows results as they
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.
4/// "What needs me": pinned repos, open MRs, assigned issues, recent
5/// builds one `dashboard` read. This replaced a per-repo fan-out that
6/// drained the rate bucket at 64 of 66 repos; krz/gitbay#41 added the
7/// aggregate.
128 @Observable
139 @MainActor
1410 final class DashboardViewModel {
1511
16 nonisolated struct RepoMR: Sendable, Hashable, Identifiable {
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?
12 private(set) var state: LoadState<DashboardData> = .loading
4313
4414 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
4915
50 init(client: GitbayClient, username: String) {
16 init(client: GitbayClient) {
5117 self.client = client
52 self.username = username
5318 }
5419
5520 func load() async {
56 scanning = true
57 scanNote = nil
58 defer { scanning = false }
59
60 let repos: [RepoSummary]
6121 do {
62 repos = try await client.readList(["repo", "list"], of: RepoSummary.self)
63 .filter { !$0.isArchived }
22 state = .loaded(try await client.read(["dashboard"], as: DashboardData.self))
6423 } catch {
65 scanNote = LoadState<Never>.from(error).failureMessage
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
24 state = .from(error)
16225 }
16326 }
16427 }
gitbay/Issues/IssueListViewModel.swift +21 −18
@@ -1,7 +1,7 @@
11 import Foundation
22 import Observation
33
4/// `issue list <repo> --state <s>`.
4/// `issue list <repo> --state <s>`, paginated.
55 @Observable
66 @MainActor
77 final class IssueListViewModel {
@@ -11,30 +11,33 @@ final class IssueListViewModel {
1111 var id: String { rawValue }
1212 }
1313
14 private(set) var state: LoadState<[Issue]> = .loading
14 let list: PagedListModel<Issue>
15 let repoPath: String
1516 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 }
1722 }
1823
19 private let client: GitbayClient
20 let repoPath: String
21
2224 init(client: GitbayClient, repoPath: String) {
23 self.client = client
2425 self.repoPath = repoPath
26 list = PagedListModel(
27 client: client,
28 argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue],
29 emptyMessage: "No open issues."
30 )
2531 }
2632
33 var state: LoadState<[Issue]> { list.state }
34
2735 func load() async {
28 state = .loading
29 do {
30 let issues = try await client.readList(
31 ["issue", "list", repoPath, "--state", filter.rawValue], of: Issue.self
32 )
33 state = issues.isEmpty
34 ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")issues.")
35 : .loaded(issues.sorted { $0.number > $1.number })
36 } catch {
37 state = .from(error)
38 }
36 await list.reload()
37 }
38
39 private func configureList() {
40 list.argv = ["issue", "list", repoPath, "--state", filter.rawValue]
41 list.emptyMessage = "No \(filter == .all ? "" : filter.rawValue + " ")issues."
3942 }
4043 }
gitbay/MRs/MRListViewModel.swift +22 −18
@@ -1,7 +1,7 @@
11 import Foundation
22 import Observation
33
4/// `mr list <repo> --state <s>`.
4/// `mr list <repo> --state <s>`, paginated.
55 @Observable
66 @MainActor
77 final class MRListViewModel {
@@ -11,30 +11,34 @@ final class MRListViewModel {
1111 var id: String { rawValue }
1212 }
1313
14 private(set) var state: LoadState<[MergeRequest]> = .loading
14 let list: PagedListModel<MergeRequest>
15 let repoPath: String
1516 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 }
1722 }
1823
19 private let client: GitbayClient
20 let repoPath: String
21
2224 init(client: GitbayClient, repoPath: String) {
23 self.client = client
2425 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 )
2531 }
2632
33 var state: LoadState<[MergeRequest]> { list.state }
34
2735 func load() async {
28 state = .loading
29 do {
30 let mrs = try await client.readList(
31 ["mr", "list", repoPath, "--state", filter.rawValue], of: MergeRequest.self
32 )
33 state = mrs.isEmpty
34 ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")merge requests.")
35 : .loaded(mrs.sorted { $0.number > $1.number })
36 } catch {
37 state = .from(error)
38 }
36 await list.reload()
37 }
38
39 private func configureList() {
40 list.argv = ["mr", "list", repoPath, "--state", filter.rawValue]
41 list.emptyMessage =
42 "No \(filter == .all ? "" : filter.rawValue + " ")merge requests."
3943 }
4044 }
gitbay/Networking/GitbayClient.swift +26
@@ -79,6 +79,32 @@ nonisolated final class GitbayClient: Sendable {
7979 return envelope.data ?? []
8080 }
8181
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
82108 /// Run a read-only command that emits raw text rather than JSON
83109 /// (`mr diff`, `build log`). The server wraps those as `output`.
84110 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 @@
11 import Foundation
22 import Observation
33
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.
57 @Observable
68 @MainActor
79 final class RepoListViewModel {
810
9 private(set) var state: LoadState<[RepoSummary]> = .loading
11 let list: PagedListModel<RepoSummary>
1012 var searchText = ""
1113
12 private let client: GitbayClient
13
1414 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 )
1621 }
1722
23 var state: LoadState<[RepoSummary]> { list.state }
24
1825 var visibleRepos: [RepoSummary] {
19 guard let repos = state.value else { return [] }
26 guard let repos = list.state.value else { return [] }
2027 let query = searchText.trimmingCharacters(in: .whitespaces).lowercased()
2128 guard !query.isEmpty else { return repos }
2229 return repos.filter {
@@ -26,13 +33,6 @@ final class RepoListViewModel {
2633 }
2734
2835 func load() async {
29 do {
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 }
36 await list.reload()
3737 }
3838 }
gitbay/Views/Dashboard/DashboardView.swift +115 −75
@@ -1,114 +1,154 @@
11 import SwiftUI
22
3/// What needs me, across every repo I can reach.
3/// What needs me, in one read.
44 struct DashboardView: View {
55
66 @State private var model: DashboardViewModel
77
8 init(client: GitbayClient, username: String) {
9 _model = State(initialValue: DashboardViewModel(client: client, username: username))
8 init(client: GitbayClient) {
9 _model = State(initialValue: DashboardViewModel(client: client))
1010 }
1111
1212 var body: some View {
1313 List {
14 if let note = model.scanNote {
15 Section {
16 Label(note, systemImage: "exclamationmark.triangle")
17 .font(.caption)
18 .foregroundStyle(.orange)
14 if let data = model.state.value {
15 if !data.pinned.isEmpty {
16 pinnedSection(data.pinned)
1917 }
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)
2029 }
30 }
31 .overlay { LoadStateOverlay(state: model.state) }
32 .navigationTitle("Dashboard")
33 .toolbar { AccountMenu() }
34 .task { await model.load() }
35 .refreshable { await model.load() }
36 }
2137
22 Section("Needs review") {
23 if model.openMRs.isEmpty {
24 emptyRow(model.scanning ? "Scanning…" : "No open merge requests.")
25 } else {
26 ForEach(model.openMRs) { entry in
27 NavigationLink(value: MRRoute.mr(repo: entry.repo, number: entry.mr.number)) {
28 VStack(alignment: .leading, spacing: 2) {
29 Text(entry.repo)
30 .font(.caption)
38 private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
39 Section("Pinned") {
40 ForEach(pinned) { repo in
41 NavigationLink(value: RepoRoute.repo(repo.path)) {
42 VStack(alignment: .leading, spacing: 2) {
43 HStack(spacing: 6) {
44 Text(repo.path)
45 .font(.subheadline.weight(.medium))
46 .lineLimit(1)
47 if repo.isPrivate {
48 Image(systemName: "lock.fill")
49 .font(.caption2)
3150 .foregroundStyle(.secondary)
32 MRRow(mr: entry.mr)
3351 }
3452 }
53 if let description = repo.description, !description.isEmpty {
54 Text(description)
55 .font(.caption)
56 .foregroundStyle(.secondary)
57 .lineLimit(1)
58 }
3559 }
3660 }
3761 }
62 }
63 }
3864
39 Section("Assigned to me") {
40 if model.assignedIssues.isEmpty {
41 emptyRow(model.scanning ? "Scanning…" : "No assigned issues.")
42 } else {
43 ForEach(model.assignedIssues) { entry in
44 NavigationLink(value: IssueRoute.issue(repo: entry.repo, number: entry.issue.number)) {
45 VStack(alignment: .leading, spacing: 2) {
46 Text(entry.repo)
47 .font(.caption)
65 private func itemSection(
66 _ title: String,
67 items: [DashboardItem],
68 empty: String,
69 marker: String,
70 route: @escaping (DashboardItem) -> some Hashable
71 ) -> some View {
72 Section(title) {
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())
4887 .foregroundStyle(.secondary)
49 HStack(spacing: 6) {
50 Text("#\(entry.issue.number)")
51 .font(.caption.monospaced())
52 .foregroundStyle(.secondary)
53 Text(entry.issue.title)
54 .font(.subheadline.weight(.medium))
55 .lineLimit(2)
56 }
88 Text(item.title)
89 .font(.subheadline.weight(.medium))
90 .lineLimit(2)
5791 }
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)
5899 }
59100 }
60101 }
61102 }
103 }
104 }
62105
63 Section("Recent builds") {
64 if model.recentBuilds.isEmpty {
65 emptyRow(model.scanning ? "Scanning…" : "No recent builds where something is open.")
66 } else {
67 ForEach(model.recentBuilds) { entry in
68 NavigationLink(value: BuildRoute.log(repo: entry.repo, number: entry.build.number)) {
69 HStack(spacing: 8) {
70 Image(systemName: entry.build.status == "success"
71 ? "checkmark.circle.fill"
72 : entry.build.status == "failure" ? "xmark.circle.fill" : "circle.dotted")
73 .foregroundStyle(entry.build.status == "success"
74 ? .green : entry.build.status == "failure" ? .red : .orange)
75 VStack(alignment: .leading, spacing: 2) {
76 Text(entry.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))
106 private func buildsSection(_ builds: [DashboardBuild]) -> some View {
107 Section("Recent builds") {
108 if builds.isEmpty {
109 Text("No recent builds.")
110 .font(.subheadline)
111 .foregroundStyle(.secondary)
112 } else {
113 ForEach(builds) { build in
114 NavigationLink(value: BuildRoute.log(repo: build.repo, number: build.number)) {
115 HStack(spacing: 8) {
116 Image(systemName: buildIcon(build.status))
117 .foregroundStyle(buildColor(build.status))
118 VStack(alignment: .leading, spacing: 2) {
119 Text(build.repo)
84120 .font(.caption)
85 .foregroundStyle(.tertiary)
121 .foregroundStyle(.secondary)
122 Text("#\(build.number) \(build.job)")
123 .font(.subheadline)
86124 }
125 Spacer()
126 Text(build.createdAt, format: .relative(presentation: .named))
127 .font(.caption)
128 .foregroundStyle(.tertiary)
87129 }
88130 }
89131 }
90132 }
133 }
134 }
91135
92 if model.scanning {
93 Section {
94 HStack {
95 ProgressView()
96 Text("Scanning \(model.scannedRepos)/\(model.totalRepos) repositories…")
97 .font(.caption)
98 .foregroundStyle(.secondary)
99 }
100 }
101 }
136 private func buildIcon(_ status: String) -> String {
137 switch status {
138 case "success": "checkmark.circle.fill"
139 case "failure", "error": "xmark.circle.fill"
140 case "running": "circle.dotted"
141 case "queued", "pending": "clock"
142 default: "questionmark.circle"
102143 }
103 .navigationTitle("Dashboard")
104 .toolbar { AccountMenu() }
105 .task { await model.load() }
106 .refreshable { await model.load() }
107144 }
108145
109 private func emptyRow(_ text: String) -> some View {
110 Text(text)
111 .font(.subheadline)
112 .foregroundStyle(.secondary)
146 private func buildColor(_ status: String) -> Color {
147 switch status {
148 case "success": .green
149 case "failure", "error": .red
150 case "running", "queued", "pending": .orange
151 default: .secondary
152 }
113153 }
114154 }
gitbay/Views/Issues/IssueListView.swift +1
@@ -24,6 +24,7 @@ struct IssueListView: View {
2424 IssueRow(issue: issue)
2525 }
2626 }
27 PageFooter(list: model.list)
2728 }
2829 .overlay { LoadStateOverlay(state: model.state) }
2930 .navigationTitle("Issues")
gitbay/Views/MRs/MRListView.swift +1
@@ -24,6 +24,7 @@ struct MRListView: View {
2424 MRRow(mr: mr)
2525 }
2626 }
27 PageFooter(list: model.list)
2728 }
2829 .overlay { LoadStateOverlay(state: model.state) }
2930 .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 {
1616 RepoRow(repo: repo)
1717 }
1818 }
19 if model.searchText.isEmpty {
20 PageFooter(list: model.list)
21 }
1922 }
2023 .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) }
2124 .searchable(text: Bindable(model).searchText, prompt: "Filter repositories")
gitbayTests/DashboardViewModelTests.swift +46 −69
@@ -12,92 +12,69 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
1212 return (client, box)
1313 }
1414
15private let twoRepoList = """
16 {"protocol_version":1,"data":[\
17 {"path":"krz/gitbay","visibility":"public"},\
18 {"path":"krz/dust","visibility":"public","archived":true},\
19 {"path":"krz/hutch","visibility":"public"}\
20 ],"exit_code":0}
15private let dashboardJSON = """
16 {"protocol_version":1,"data":{\
17 "pinned":[{"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"}],\
18 "open_mrs":[{"repo":"krz/gitbay-ios","number":3,"title":"adopt aggregate","author":"cmc",\
19 "state":"open","updated_at":"2026-08-27T10:00:00.000Z"}],\
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}
2126 """
2227
23private let openMRJSON = """
24 {"protocol_version":1,"data":[\
25 {"number":7,"title":"fix","state":"open","author":"krz","source":"fix",\
26 "target_ref":"main","head_sha":"aa","created_at":"2026-08-22T10:00:00.000Z"}\
27 ],"exit_code":0}
28private let emptyDashboardJSON = """
29 {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\
30 "exit_code":0}
2831 """
2932
30private let assignedIssueJSON = """
31 {"protocol_version":1,"data":[\
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 """
33@MainActor
34struct DashboardViewModelTests {
3835
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)
4040
41private let buildJSON = """
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 """
41 await model.load()
4742
48@MainActor
49struct DashboardViewModelTests {
43 let data = try #require(model.state.value)
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 }
5052
51 @Test func aggregatesAcrossReposSkippingArchivedOnes() async throws {
53 @Test func emptyAggregateStillLoads() async throws {
5254 let (client, stub) = try makeClient()
53 stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list"))
54 // krz/gitbay: one open MR, one assigned + one unassigned issue, a build.
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")
55 stub.enqueue(.init(status: 200, json: emptyDashboardJSON))
56 let model = DashboardViewModel(client: client)
6257
6358 await model.load()
6459
65 #expect(model.openMRs.map(\.id) == ["krz/gitbay!7"])
66 // Only issues assigned to me, and never from the archived repo.
67 #expect(model.assignedIssues.map(\.id) == ["krz/gitbay#3"])
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)
60 let data = try #require(model.state.value)
61 #expect(data.pinned.isEmpty)
62 #expect(data.openMRs.isEmpty)
7463 }
7564
76 @Test func rateLimitStopsTheScanAndSaysSo() async throws {
65 @Test func aFailureIsTheScreensState() async throws {
7766 let (client, stub) = try makeClient()
78 stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list"))
79 stub.enqueue(.init(status: 200, json: openMRJSON, match: "argv=mr&argv=list&argv=krz/gitbay"))
80 stub.enqueue(.init(status: 200, json: assignedIssueJSON, match: "argv=issue&argv=list&argv=krz/gitbay"))
81 stub.enqueue(.init(status: 200, json: buildJSON, match: "argv=build&argv=list&argv=krz/gitbay"))
82 stub.enqueue(.init(
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")
67 stub.enqueue(.init(status: 500, json:
68 #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
69 stub.enqueue(.init(status: 500, json:
70 #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
71 let model = DashboardViewModel(client: client)
9572
9673 await model.load()
9774
98 // What arrived before the limit is still on screen.
99 #expect(model.openMRs.count == 1)
100 let note = try #require(model.scanNote)
101 #expect(note.contains("Rate limited"))
75 guard case .failed = model.state else {
76 Issue.record("expected .failed, got \(model.state)")
77 return
78 }
10279 }
10380 }
gitbayTests/IssueBuildViewModelTests.swift +7 −7
@@ -13,12 +13,12 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
1313 }
1414
1515 private let issueListJSON = """
16 {"protocol_version":1,"data":[\
16 {"protocol_version":1,"data":{"items":[\
1717 {"number":11,"title":"iOS app","state":"open","author":"krz",\
1818 "labels":["app"],"assignees":["cmc"],"created_at":"2026-08-20T10:00:00.000Z"},\
1919 {"number":40,"title":"pagination","state":"open","author":"krz",\
2020 "created_at":"2026-08-21T10:00:00.000Z"}\
21 ],"exit_code":0}
21 ]},"exit_code":0}
2222 """
2323
2424 private let issueShowJSON = """
@@ -42,7 +42,7 @@ private let buildListJSON = """
4242 @MainActor
4343 struct IssueListViewModelTests {
4444
45 @Test func listsNewestFirstWithLabelsAndAssignees() async throws {
45 @Test func listsInServerOrderWithLabelsAndAssignees() async throws {
4646 let (client, stub) = try makeClient()
4747 stub.enqueue(.init(status: 200, json: issueListJSON))
4848 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
@@ -50,11 +50,11 @@ struct IssueListViewModelTests {
5050 await model.load()
5151
5252 let issues = try #require(model.state.value)
53 #expect(issues.map(\.number) == [40, 11])
54 #expect(issues[1].labels == ["app"])
55 #expect(issues[1].assignees == ["cmc"])
53 #expect(issues.map(\.number) == [11, 40])
54 #expect(issues[0].labels == ["app"])
55 #expect(issues[0].assignees == ["cmc"])
5656 #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")
5858 }
5959 }
6060
gitbayTests/MRViewModelTests.swift +6 −6
@@ -13,14 +13,14 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
1313 }
1414
1515 private let mrListJSON = """
16 {"protocol_version":1,"data":[\
16 {"protocol_version":1,"data":{"items":[\
1717 {"number":7,"title":"client: envelope decoding","state":"open","author":"cmc",\
1818 "source":"client-envelope","target_ref":"main","head_sha":"aabbcc",\
1919 "created_at":"2026-08-20T10:00:00.000Z"},\
2020 {"number":9,"title":"auth: keychain","state":"open","author":"cmc",\
2121 "source":"krz/fork:auth","target_ref":"main","head_sha":"ddeeff",\
2222 "created_at":"2026-08-21T10:00:00.000Z"}\
23 ],"exit_code":0}
23 ]},"exit_code":0}
2424 """
2525
2626 private let mrShowJSON = """
@@ -140,7 +140,7 @@ struct UnifiedDiffParserTests {
140140 @MainActor
141141 struct MRListViewModelTests {
142142
143 @Test func listsNewestFirst() async throws {
143 @Test func listsInServerOrderWithPagingFlags() async throws {
144144 let (client, stub) = try makeClient()
145145 stub.enqueue(.init(status: 200, json: mrListJSON))
146146 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
@@ -148,16 +148,16 @@ struct MRListViewModelTests {
148148 await model.load()
149149
150150 let mrs = try #require(model.state.value)
151 #expect(mrs.map(\.number) == [9, 7])
151 #expect(mrs.map(\.number) == [7, 9])
152152 let seen = try #require(stub.seen.first)
153153 #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")
155155 }
156156
157157 @Test func changingTheFilterReloadsWithThatState() async throws {
158158 let (client, stub) = try makeClient()
159159 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}"#))
161161 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
162162 await model.load()
163163
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) {
1313 }
1414
1515 private let repoListJSON = """
16 {"protocol_version":1,"data":[\
17 {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\
16 {"protocol_version":1,"data":{"items":[\
1817 {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\
18 {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\
1919 {"path":"krz/secrets","visibility":"private","archived":true}\
20 ],"exit_code":0}
20 ]},"exit_code":0}
2121 """
2222
2323 private let repoShowJSON = """
@@ -61,7 +61,7 @@ private let logJSON = """
6161 @MainActor
6262 struct RepoListViewModelTests {
6363
64 @Test func loadsAndSortsByPath() async throws {
64 @Test func loadsOnePageInServerOrder() async throws {
6565 let (client, stub) = try makeClient()
6666 stub.enqueue(.init(status: 200, json: repoListJSON))
6767 let model = RepoListViewModel(client: client)
@@ -71,6 +71,8 @@ struct RepoListViewModelTests {
7171 #expect(model.visibleRepos.map(\.path) == ["krz/gitbay", "krz/hutch", "krz/secrets"])
7272 #expect(model.visibleRepos[2].isPrivate)
7373 #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")
7476 }
7577
7678 @Test func filterMatchesPathAndDescription() async throws {
@@ -88,7 +90,7 @@ struct RepoListViewModelTests {
8890
8991 @Test func anEmptyListIsAnEmptyStateNotAnError() async throws {
9092 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}"#))
9294 let model = RepoListViewModel(client: client)
9395
9496 await model.load()