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 {
102102 MilestoneListView(client: client, repo: repo)
103103 case .labels(let repo):
104104 LabelListView(client: client, repo: repo)
105 case .bookmarks:
106 BookmarkListView(client: client)
105107 case .wiki(let repo):
106108 WikiView(client: client, repo: repo)
107109 case .wikiPage(let repo, let page):
gitbay/Repos/BookmarkListViewModel.swift added +30
@@ -0,0 +1,30 @@
1import Foundation
2import 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
9final 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 @@
1import SwiftUI
2
3/// The bookmarks list, reached from the Repositories tab.
4struct 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 {
2727 .searchable(text: Bindable(model).searchText, prompt: "Filter repositories")
2828 .navigationTitle("Repositories")
2929 .toolbar {
30 ToolbarItem(placement: .topBarTrailing) {
31 NavigationLink(value: RepoRoute.bookmarks) {
32 Image(systemName: "bookmark")
33 }
34 .accessibilityIdentifier("repo-bookmarks-button")
35 }
3036 ToolbarItem(placement: .topBarTrailing) {
3137 Button {
3238 composing = true
@@ -118,7 +124,7 @@ struct RepoCreateSheet: View {
118124 }
119125}
120126
121private struct RepoRow: View {
127struct RepoRow: View {
122128 let repo: RepoSummary
123129
124130 var body: some View {
gitbay/Views/Repos/RepoRoute.swift +1
@@ -17,6 +17,7 @@ nonisolated enum RepoRoute: Hashable {
1717 case wiki(repo: String)
1818 case wikiPage(repo: String, page: String)
1919 case commit(repo: String, sha: String)
20 case bookmarks
2021 case profile(String)
2122 case account
2223 case addAccount
gitbayTests/BookmarkTests.swift added +50
@@ -0,0 +1,50 @@
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 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
23struct 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 {
440440 XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take")
441441 unpin.tap()
442442
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
443462 // --- settings: topic and merge-rule round-trips ---
444463 app.staticTexts["Settings"].firstMatch.tap()
445464 let addTopic = app.descendants(matching: .any)