Commit b93b686b84
b93b686b84f4f40b383e64d60c3bbd08b7541c88
parent: 96bfba0e47
Unsigned
cmc <hello@cleberg.net> · 2026-09-07 17:15 UTC
Bookmarks list (!68)
repo bookmarks behind a bookmark button on the Repositories tab. Rows reuse RepoRow, now internal. Unit tests cover the read and the empty state; the live suite bookmarks the fixture repo, finds it in the list, and unbookmarks it.
Layout: unified · split
gitbay/ContentView.swift
+2
| @@ -102,6 +102,8 @@ private struct RouteDestinations: ViewModifier { |
| 102 | 102 | MilestoneListView(client: client, repo: repo) |
| 103 | 103 | case .labels(let repo): |
| 104 | 104 | LabelListView(client: client, repo: repo) |
| 105 | case .bookmarks: |
| 106 | BookmarkListView(client: client) |
| 105 | 107 | case .wiki(let repo): |
| 106 | 108 | WikiView(client: client, repo: repo) |
| 107 | 109 | case .wikiPage(let repo, let page): |
gitbay/Repos/BookmarkListViewModel.swift
added
+30
| @@ -0,0 +1,30 @@ |
| 1 | import Foundation |
| 2 | import Observation |
| 3 | |
| 4 | /// `repo bookmarks` — the caller's public bookmarks, in server order. |
| 5 | /// The rows decode as `RepoSummary`; a repository made private since it |
| 6 | /// was bookmarked is already left out by the server. |
| 7 | @Observable |
| 8 | @MainActor |
| 9 | final class BookmarkListViewModel { |
| 10 | |
| 11 | private(set) var state: LoadState<[RepoSummary]> = .loading |
| 12 | |
| 13 | private let client: GitbayClient |
| 14 | |
| 15 | init(client: GitbayClient) { |
| 16 | self.client = client |
| 17 | } |
| 18 | |
| 19 | func load() async { |
| 20 | state = .loading |
| 21 | do { |
| 22 | let repos = try await client.readList(["repo", "bookmarks"], of: RepoSummary.self) |
| 23 | state = repos.isEmpty |
| 24 | ? .empty("No bookmarks yet. Bookmark a repository from its actions menu.") |
| 25 | : .loaded(repos) |
| 26 | } catch { |
| 27 | state = .from(error) |
| 28 | } |
| 29 | } |
| 30 | } |
gitbay/Views/Repos/BookmarkListView.swift
added
+26
| @@ -0,0 +1,26 @@ |
| 1 | import SwiftUI |
| 2 | |
| 3 | /// The bookmarks list, reached from the Repositories tab. |
| 4 | struct BookmarkListView: View { |
| 5 | |
| 6 | @State private var model: BookmarkListViewModel |
| 7 | |
| 8 | init(client: GitbayClient) { |
| 9 | _model = State(initialValue: BookmarkListViewModel(client: client)) |
| 10 | } |
| 11 | |
| 12 | var body: some View { |
| 13 | List { |
| 14 | ForEach(model.state.value ?? []) { repo in |
| 15 | NavigationLink(value: RepoRoute.repo(repo.path)) { |
| 16 | RepoRow(repo: repo) |
| 17 | } |
| 18 | } |
| 19 | } |
| 20 | .overlay { LoadStateOverlay(state: model.state) } |
| 21 | .navigationTitle("Bookmarks") |
| 22 | .navigationBarTitleDisplayMode(.inline) |
| 23 | .task { await model.load() } |
| 24 | .refreshable { await model.load() } |
| 25 | } |
| 26 | } |
gitbay/Views/Repos/RepoListView.swift
+7 −1
| @@ -27,6 +27,12 @@ struct RepoListView: View { |
| 27 | 27 | .searchable(text: Bindable(model).searchText, prompt: "Filter repositories") |
| 28 | 28 | .navigationTitle("Repositories") |
| 29 | 29 | .toolbar { |
| 30 | ToolbarItem(placement: .topBarTrailing) { |
| 31 | NavigationLink(value: RepoRoute.bookmarks) { |
| 32 | Image(systemName: "bookmark") |
| 33 | } |
| 34 | .accessibilityIdentifier("repo-bookmarks-button") |
| 35 | } |
| 30 | 36 | ToolbarItem(placement: .topBarTrailing) { |
| 31 | 37 | Button { |
| 32 | 38 | composing = true |
| @@ -118,7 +124,7 @@ struct RepoCreateSheet: View { |
| 118 | 124 | } |
| 119 | 125 | } |
| 120 | 126 | |
| 121 | | private struct RepoRow: View { |
| 127 | struct RepoRow: View { |
| 122 | 128 | let repo: RepoSummary |
| 123 | 129 | |
| 124 | 130 | var body: some View { |
gitbay/Views/Repos/RepoRoute.swift
+1
| @@ -17,6 +17,7 @@ nonisolated enum RepoRoute: Hashable { |
| 17 | 17 | case wiki(repo: String) |
| 18 | 18 | case wikiPage(repo: String, page: String) |
| 19 | 19 | case commit(repo: String, sha: String) |
| 20 | case bookmarks |
| 20 | 21 | case profile(String) |
| 21 | 22 | case account |
| 22 | 23 | case addAccount |
gitbayTests/BookmarkTests.swift
added
+50
| @@ -0,0 +1,50 @@ |
| 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 let bookmarksJSON = """ |
| 16 | {"protocol_version":1,"data":[\ |
| 17 | {"path":"krz/gitbay","description":"A CLI-first git forge.","visibility":"public","bookmarks":3},\ |
| 18 | {"path":"cmc/notes","description":"","visibility":"private","bookmarks":1}\ |
| 19 | ],"exit_code":0} |
| 20 | """ |
| 21 | |
| 22 | @MainActor |
| 23 | struct BookmarkListViewModelTests { |
| 24 | |
| 25 | @Test func listsBookmarkedRepositories() async throws { |
| 26 | let (client, stub) = try makeClient() |
| 27 | stub.enqueue(.init(status: 200, json: bookmarksJSON)) |
| 28 | let model = BookmarkListViewModel(client: client) |
| 29 | |
| 30 | await model.load() |
| 31 | |
| 32 | let repos = try #require(model.state.value) |
| 33 | #expect(repos.map(\.path) == ["krz/gitbay", "cmc/notes"]) |
| 34 | #expect(repos[1].isPrivate) |
| 35 | #expect(stub.seen.first?.url.query() == "argv=repo&argv=bookmarks") |
| 36 | } |
| 37 | |
| 38 | @Test func noBookmarksIsAnEmptyStateNotAFailure() async throws { |
| 39 | let (client, stub) = try makeClient() |
| 40 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[],"exit_code":0}"#)) |
| 41 | let model = BookmarkListViewModel(client: client) |
| 42 | |
| 43 | await model.load() |
| 44 | |
| 45 | guard case .empty = model.state else { |
| 46 | Issue.record("expected an empty state, got \(model.state)") |
| 47 | return |
| 48 | } |
| 49 | } |
| 50 | } |
gitbayUITests/LiveSmokeUITests.swift
+19
| @@ -440,6 +440,25 @@ extension LiveSmokeUITests { |
| 440 | 440 | XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take") |
| 441 | 441 | unpin.tap() |
| 442 | 442 | |
| 443 | // Bookmark it, find it in the bookmarks list, then unbookmark. |
| 444 | menu.tap() |
| 445 | let bookmark = app.buttons["Bookmark"].firstMatch |
| 446 | XCTAssertTrue(bookmark.waitForExistence(timeout: 5), "Bookmark action missing") |
| 447 | bookmark.tap() |
| 448 | back() // repo -> list |
| 449 | app.descendants(matching: .any).matching(identifier: "repo-bookmarks-button") |
| 450 | .firstMatch.tap() |
| 451 | XCTAssertTrue(app.staticTexts["krz/gitbay-ios"].firstMatch |
| 452 | .waitForExistence(timeout: 15), "bookmarked repo not in the bookmarks list") |
| 453 | back() // bookmarks -> list |
| 454 | XCTAssertTrue(repoRow.waitForExistence(timeout: 10)) |
| 455 | repoRow.tap() |
| 456 | XCTAssertTrue(menu.waitForExistence(timeout: 10)) |
| 457 | menu.tap() |
| 458 | let unbookmark = app.buttons["Unbookmark"].firstMatch |
| 459 | XCTAssertTrue(unbookmark.waitForExistence(timeout: 10), "bookmark did not take") |
| 460 | unbookmark.tap() |
| 461 | |
| 443 | 462 | // --- settings: topic and merge-rule round-trips --- |
| 444 | 463 | app.staticTexts["Settings"].firstMatch.tap() |
| 445 | 464 | let addTopic = app.descendants(matching: .any) |