Commit b889230698
Verified · cmc
gitbay/ContentView.swift +1 −1
| @@ -9,7 +9,7 @@ struct ContentView: View { | ||
| 9 | 9 | TabView { |
| 10 | 10 | Tab("Dashboard", systemImage: "square.grid.2x2") { |
| 11 | 11 | NavigationStack { |
| 12 | DashboardView(client: client, username: account.username) | |
| 12 | DashboardView(client: client) | |
| 13 | 13 | .navigationDestinations(client: client) |
| 14 | 14 | } |
| 15 | 15 | } |
gitbay/Dashboard/DashboardModels.swift added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// The `dashboard` command: the whole account aggregate in one read. | |
| 4 | /// `internal/control/dashboard.go` — arrays are always present. | |
| 5 | nonisolated 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. | |
| 20 | nonisolated 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. | |
| 37 | nonisolated 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 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import Observation |
| 3 | 3 | |
| 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. | |
| 12 | 8 | @Observable |
| 13 | 9 | @MainActor |
| 14 | 10 | final class DashboardViewModel { |
| 15 | 11 | |
| 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 | |
| 43 | 13 | |
| 44 | 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 | 17 | self.client = client |
| 52 | self.username = username | |
| 53 | 18 | } |
| 54 | 19 | |
| 55 | 20 | func load() async { |
| 56 | scanning = true | |
| 57 | scanNote = nil | |
| 58 | defer { scanning = false } | |
| 59 | ||
| 60 | let repos: [RepoSummary] | |
| 61 | 21 | 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)) | |
| 64 | 23 | } 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 | ||
| 156 | extension 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) | |
| 162 | 25 | } |
| 163 | 26 | } |
| 164 | 27 | } |
gitbay/Issues/IssueListViewModel.swift +21 −18
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import Observation |
| 3 | 3 | |
| 4 | /// `issue list <repo> --state <s>`. | |
| 4 | /// `issue list <repo> --state <s>`, paginated. | |
| 5 | 5 | @Observable |
| 6 | 6 | @MainActor |
| 7 | 7 | final class IssueListViewModel { |
| @@ -11,30 +11,33 @@ final class IssueListViewModel { | ||
| 11 | 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 | 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 | 24 | init(client: GitbayClient, repoPath: String) { |
| 23 | self.client = client | |
| 24 | 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 | 35 | 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." | |
| 39 | 42 | } |
| 40 | 43 | } |
gitbay/MRs/MRListViewModel.swift +22 −18
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import Observation |
| 3 | 3 | |
| 4 | /// `mr list <repo> --state <s>`. | |
| 4 | /// `mr list <repo> --state <s>`, paginated. | |
| 5 | 5 | @Observable |
| 6 | 6 | @MainActor |
| 7 | 7 | final class MRListViewModel { |
| @@ -11,30 +11,34 @@ final class MRListViewModel { | ||
| 11 | 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 | 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 | 24 | init(client: GitbayClient, repoPath: String) { |
| 23 | self.client = client | |
| 24 | 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 | 35 | 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." | |
| 39 | 43 | } |
| 40 | 44 | } |
gitbay/Networking/GitbayClient.swift +26
| @@ -79,6 +79,32 @@ nonisolated final class GitbayClient: Sendable { | ||
| 79 | 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 | 108 | /// Run a read-only command that emits raw text rather than JSON |
| 83 | 109 | /// (`mr diff`, `build log`). The server wraps those as `output`. |
| 84 | 110 | func readText(_ argv: [String]) async throws -> String { |
gitbay/Repos/PagedListModel.swift added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | |
| 9 | final 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 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import 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 | 7 | @Observable |
| 6 | 8 | @MainActor |
| 7 | 9 | final class RepoListViewModel { |
| 8 | 10 | |
| 9 | private(set) var state: LoadState<[RepoSummary]> = .loading | |
| 11 | let list: PagedListModel<RepoSummary> | |
| 10 | 12 | var searchText = "" |
| 11 | 13 | |
| 12 | private let client: GitbayClient | |
| 13 | ||
| 14 | 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 | 25 | var visibleRepos: [RepoSummary] { |
| 19 | guard let repos = state.value else { return [] } | |
| 26 | guard let repos = list.state.value else { return [] } | |
| 20 | 27 | let query = searchText.trimmingCharacters(in: .whitespaces).lowercased() |
| 21 | 28 | guard !query.isEmpty else { return repos } |
| 22 | 29 | return repos.filter { |
| @@ -26,13 +33,6 @@ final class RepoListViewModel { | ||
| 26 | 33 | } |
| 27 | 34 | |
| 28 | 35 | 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() | |
| 37 | 37 | } |
| 38 | 38 | } |
gitbay/Views/Dashboard/DashboardView.swift +115 −75
| @@ -1,114 +1,154 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | /// What needs me, across every repo I can reach. | |
| 3 | /// What needs me, in one read. | |
| 4 | 4 | struct DashboardView: View { |
| 5 | 5 | |
| 6 | 6 | @State private var model: DashboardViewModel |
| 7 | 7 | |
| 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)) | |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | 12 | var body: some View { |
| 13 | 13 | 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) | |
| 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") { | |
| 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) | |
| 31 | 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") { | |
| 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()) | |
| 48 | 87 | .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) | |
| 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") { | |
| 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) | |
| 84 | 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 { | |
| 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" | |
| 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 { | |
| 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 | } | |
| 113 | 153 | } |
| 114 | 154 | } |
gitbay/Views/Issues/IssueListView.swift +1
| @@ -24,6 +24,7 @@ struct IssueListView: View { | ||
| 24 | 24 | IssueRow(issue: issue) |
| 25 | 25 | } |
| 26 | 26 | } |
| 27 | PageFooter(list: model.list) | |
| 27 | 28 | } |
| 28 | 29 | .overlay { LoadStateOverlay(state: model.state) } |
| 29 | 30 | .navigationTitle("Issues") |
gitbay/Views/MRs/MRListView.swift +1
| @@ -24,6 +24,7 @@ struct MRListView: View { | ||
| 24 | 24 | MRRow(mr: mr) |
| 25 | 25 | } |
| 26 | 26 | } |
| 27 | PageFooter(list: model.list) | |
| 27 | 28 | } |
| 28 | 29 | .overlay { LoadStateOverlay(state: model.state) } |
| 29 | 30 | .navigationTitle("Merge Requests") |
gitbay/Views/Repos/PageFooter.swift added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | import 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. | |
| 5 | struct 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 | 16 | RepoRow(repo: repo) |
| 17 | 17 | } |
| 18 | 18 | } |
| 19 | if model.searchText.isEmpty { | |
| 20 | PageFooter(list: model.list) | |
| 21 | } | |
| 19 | 22 | } |
| 20 | 23 | .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) } |
| 21 | 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 | 12 | return (client, box) |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | private 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} | |
| 15 | private 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} | |
| 21 | 26 | """ |
| 22 | 27 | |
| 23 | private 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} | |
| 28 | private let emptyDashboardJSON = """ | |
| 29 | {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\ | |
| 30 | "exit_code":0} | |
| 28 | 31 | """ |
| 29 | 32 | |
| 30 | private 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 | |
| 34 | struct DashboardViewModelTests { | |
| 38 | 35 | |
| 39 | private 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 | |
| 41 | private 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() | |
| 47 | 42 | |
| 48 | @MainActor | |
| 49 | struct 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 | } | |
| 50 | 52 | |
| 51 | @Test func aggregatesAcrossReposSkippingArchivedOnes() async throws { | |
| 53 | @Test func emptyAggregateStillLoads() async throws { | |
| 52 | 54 | 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) | |
| 62 | 57 | |
| 63 | 58 | await model.load() |
| 64 | 59 | |
| 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) | |
| 74 | 63 | } |
| 75 | 64 | |
| 76 | @Test func rateLimitStopsTheScanAndSaysSo() async throws { | |
| 65 | @Test func aFailureIsTheScreensState() async throws { | |
| 77 | 66 | 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) | |
| 95 | 72 | |
| 96 | 73 | await model.load() |
| 97 | 74 | |
| 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 | } | |
| 102 | 79 | } |
| 103 | 80 | } |
gitbayTests/IssueBuildViewModelTests.swift +7 −7
| @@ -13,12 +13,12 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | private let issueListJSON = """ |
| 16 | {"protocol_version":1,"data":[\ | |
| 16 | {"protocol_version":1,"data":{"items":[\ | |
| 17 | 17 | {"number":11,"title":"iOS app","state":"open","author":"krz",\ |
| 18 | 18 | "labels":["app"],"assignees":["cmc"],"created_at":"2026-08-20T10:00:00.000Z"},\ |
| 19 | 19 | {"number":40,"title":"pagination","state":"open","author":"krz",\ |
| 20 | 20 | "created_at":"2026-08-21T10:00:00.000Z"}\ |
| 21 | ],"exit_code":0} | |
| 21 | ]},"exit_code":0} | |
| 22 | 22 | """ |
| 23 | 23 | |
| 24 | 24 | private let issueShowJSON = """ |
| @@ -42,7 +42,7 @@ private let buildListJSON = """ | ||
| 42 | 42 | @MainActor |
| 43 | 43 | struct IssueListViewModelTests { |
| 44 | 44 | |
| 45 | @Test func listsNewestFirstWithLabelsAndAssignees() async throws { | |
| 45 | @Test func listsInServerOrderWithLabelsAndAssignees() async throws { | |
| 46 | 46 | let (client, stub) = try makeClient() |
| 47 | 47 | stub.enqueue(.init(status: 200, json: issueListJSON)) |
| 48 | 48 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") |
| @@ -50,11 +50,11 @@ struct IssueListViewModelTests { | ||
| 50 | 50 | await model.load() |
| 51 | 51 | |
| 52 | 52 | 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"]) | |
| 56 | 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 | |
| 15 | 15 | private let mrListJSON = """ |
| 16 | {"protocol_version":1,"data":[\ | |
| 16 | {"protocol_version":1,"data":{"items":[\ | |
| 17 | 17 | {"number":7,"title":"client: envelope decoding","state":"open","author":"cmc",\ |
| 18 | 18 | "source":"client-envelope","target_ref":"main","head_sha":"aabbcc",\ |
| 19 | 19 | "created_at":"2026-08-20T10:00:00.000Z"},\ |
| 20 | 20 | {"number":9,"title":"auth: keychain","state":"open","author":"cmc",\ |
| 21 | 21 | "source":"krz/fork:auth","target_ref":"main","head_sha":"ddeeff",\ |
| 22 | 22 | "created_at":"2026-08-21T10:00:00.000Z"}\ |
| 23 | ],"exit_code":0} | |
| 23 | ]},"exit_code":0} | |
| 24 | 24 | """ |
| 25 | 25 | |
| 26 | 26 | private let mrShowJSON = """ |
| @@ -140,7 +140,7 @@ struct UnifiedDiffParserTests { | ||
| 140 | 140 | @MainActor |
| 141 | 141 | struct MRListViewModelTests { |
| 142 | 142 | |
| 143 | @Test func listsNewestFirst() async throws { | |
| 143 | @Test func listsInServerOrderWithPagingFlags() async throws { | |
| 144 | 144 | let (client, stub) = try makeClient() |
| 145 | 145 | stub.enqueue(.init(status: 200, json: mrListJSON)) |
| 146 | 146 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") |
| @@ -148,16 +148,16 @@ struct MRListViewModelTests { | ||
| 148 | 148 | await model.load() |
| 149 | 149 | |
| 150 | 150 | let mrs = try #require(model.state.value) |
| 151 | #expect(mrs.map(\.number) == [9, 7]) | |
| 151 | #expect(mrs.map(\.number) == [7, 9]) | |
| 152 | 152 | let seen = try #require(stub.seen.first) |
| 153 | 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 | 157 | @Test func changingTheFilterReloadsWithThatState() async throws { |
| 158 | 158 | let (client, stub) = try makeClient() |
| 159 | 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 | 161 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") |
| 162 | 162 | await model.load() |
| 163 | 163 | |
gitbayTests/PagedListTests.swift added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private nonisolated struct Row: Decodable, Sendable, Equatable { | |
| 16 | let number: Int | |
| 17 | } | |
| 18 | ||
| 19 | private 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 | ||
| 25 | struct 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 | |
| 49 | struct 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 | |
| 15 | 15 | private let repoListJSON = """ |
| 16 | {"protocol_version":1,"data":[\ | |
| 17 | {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\ | |
| 16 | {"protocol_version":1,"data":{"items":[\ | |
| 18 | 17 | {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\ |
| 18 | {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\ | |
| 19 | 19 | {"path":"krz/secrets","visibility":"private","archived":true}\ |
| 20 | ],"exit_code":0} | |
| 20 | ]},"exit_code":0} | |
| 21 | 21 | """ |
| 22 | 22 | |
| 23 | 23 | private let repoShowJSON = """ |
| @@ -61,7 +61,7 @@ private let logJSON = """ | ||
| 61 | 61 | @MainActor |
| 62 | 62 | struct RepoListViewModelTests { |
| 63 | 63 | |
| 64 | @Test func loadsAndSortsByPath() async throws { | |
| 64 | @Test func loadsOnePageInServerOrder() async throws { | |
| 65 | 65 | let (client, stub) = try makeClient() |
| 66 | 66 | stub.enqueue(.init(status: 200, json: repoListJSON)) |
| 67 | 67 | let model = RepoListViewModel(client: client) |
| @@ -71,6 +71,8 @@ struct RepoListViewModelTests { | ||
| 71 | 71 | #expect(model.visibleRepos.map(\.path) == ["krz/gitbay", "krz/hutch", "krz/secrets"]) |
| 72 | 72 | #expect(model.visibleRepos[2].isPrivate) |
| 73 | 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 | 78 | @Test func filterMatchesPathAndDescription() async throws { |
| @@ -88,7 +90,7 @@ struct RepoListViewModelTests { | ||
| 88 | 90 | |
| 89 | 91 | @Test func anEmptyListIsAnEmptyStateNotAnError() async throws { |
| 90 | 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 | 94 | let model = RepoListViewModel(client: client) |
| 93 | 95 | |
| 94 | 96 | await model.load() |