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 | MilestoneListView(client: client, repo: repo) |
102 | MilestoneListView(client: client, repo: repo) |
| 103 | case .labels(let repo): |
103 | case .labels(let repo): |
| 104 | LabelListView(client: client, repo: repo) |
104 | LabelListView(client: client, repo: repo) |
| |
105 | case .bookmarks: |
| |
106 | BookmarkListView(client: client) |
| 105 | case .wiki(let repo): |
107 | case .wiki(let repo): |
| 106 | WikiView(client: client, repo: repo) |
108 | WikiView(client: client, repo: repo) |
| 107 | case .wikiPage(let repo, let page): |
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 | .searchable(text: Bindable(model).searchText, prompt: "Filter repositories") |
27 | .searchable(text: Bindable(model).searchText, prompt: "Filter repositories") |
| 28 | .navigationTitle("Repositories") |
28 | .navigationTitle("Repositories") |
| 29 | .toolbar { |
29 | .toolbar { |
| |
30 | ToolbarItem(placement: .topBarTrailing) { |
| |
31 | NavigationLink(value: RepoRoute.bookmarks) { |
| |
32 | Image(systemName: "bookmark") |
| |
33 | } |
| |
34 | .accessibilityIdentifier("repo-bookmarks-button") |
| |
35 | } |
| 30 | ToolbarItem(placement: .topBarTrailing) { |
36 | ToolbarItem(placement: .topBarTrailing) { |
| 31 | Button { |
37 | Button { |
| 32 | composing = true |
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 | let repo: RepoSummary |
128 | let repo: RepoSummary |
| 123 | |
129 | |
| 124 | var body: some View { |
130 | var body: some View { |
gitbay/Views/Repos/RepoRoute.swift
+1
| @@ -17,6 +17,7 @@ nonisolated enum RepoRoute: Hashable { |
| 17 | case wiki(repo: String) |
17 | case wiki(repo: String) |
| 18 | case wikiPage(repo: String, page: String) |
18 | case wikiPage(repo: String, page: String) |
| 19 | case commit(repo: String, sha: String) |
19 | case commit(repo: String, sha: String) |
| |
20 | case bookmarks |
| 20 | case profile(String) |
21 | case profile(String) |
| 21 | case account |
22 | case account |
| 22 | case addAccount |
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 | XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take") |
440 | XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take") |
| 441 | unpin.tap() |
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 | // --- settings: topic and merge-rule round-trips --- |
462 | // --- settings: topic and merge-rule round-trips --- |
| 444 | app.staticTexts["Settings"].firstMatch.tap() |
463 | app.staticTexts["Settings"].firstMatch.tap() |
| 445 | let addTopic = app.descendants(matching: .any) |
464 | let addTopic = app.descendants(matching: .any) |