a native ios client for gitbay

client ios swift

https://gitbay.org

Commit 9b5c36e88f

9b5c36e88fee7d1de91f58740987c153102fa2c7

parent: 31e9048e43

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-28T03:58:35Z

explore, and history at a ref and per file

krz/gitbay!101 made these reachable; this is the iOS half.

An Explore tab lists what the instance hosts, paginated, topics as
accent chips. repo search needs a query, so without this there was no
way to see a repository you did not already know the name of.

History follows the web's placement: the tree screen offers it for the
ref being browsed, and a file offers its own — blob.html and tree.html
put the links exactly there. My first attempt was a swipe action on
the refs list, which the web has no equivalent for and which did not
work: a NavigationLink inside swipeActions does not render as a
tappable button.

Not done, and worth a decision rather than a silent skip: archive
download. repo download exists and works over SSH, but its output is
gzip, and the JSON read surface can only carry a command's stdout as a
string — binary would be mangled. The web's route needs a session
cookie, not a bearer token, so linking to it works for public
repositories and fails for private ones. See the MR.

174 unit tests. Live smoke green: Explore listing repositories this
account does not own, and history at a ref from the tree.

Ref #11
gitbay/ContentView.swift +8 −2
@@ -25,6 +25,12 @@ struct ContentView: View {
2525 .navigationDestinations(client: client)
2626 }
2727 }
28 Tab("Explore", systemImage: "safari") {
29 NavigationStack {
30 ExploreView(client: client)
31 .navigationDestinations(client: client)
32 }
33 }
2834 }
2935 .id(account.id) // fresh screens on account switch
3036 } else {
@@ -72,8 +78,8 @@ private struct RouteDestinations: ViewModifier {
7278 TreeView(client: client, repo: repo, directory: directory, ref: ref)
7379 case .file(let repo, let path, let ref):
7480 FileView(client: client, repo: repo, path: path, ref: ref)
75 case .log(let repo):
76 LogView(client: client, repo: repo)
81 case .log(let repo, let ref, let path):
82 LogView(client: client, repo: repo, ref: ref, path: path)
7783 case .settings(let repo):
7884 RepoSettingsView(client: client, repo: repo)
7985 case .grep(let repo):
gitbay/Discovery/ExploreViewModel.swift added +14
@@ -0,0 +1,14 @@
1import Foundation
2
3/// One row of `explore` a public repository on this instance.
4nonisolated struct PublicRepo: Decodable, Sendable, Hashable, Identifiable {
5 let path: String
6 let description: String?
7 let archived: Bool?
8 let topics: [String]?
9
10 var id: String { path }
11 var isArchived: Bool { archived ?? false }
12 var name: String { String(path.split(separator: "/").last ?? "") }
13 var owner: String { String(path.split(separator: "/").first ?? "") }
14}
gitbay/Repos/LogViewModel.swift +11 −2
@@ -10,15 +10,24 @@ final class LogViewModel {
1010
1111 private let client: GitbayClient
1212 let repoPath: String
13 /// nil reads the default branch, as the command does.
14 let ref: String?
15 /// Set to follow one file's history, as the blob page does.
16 let path: String?
1317
14 init(client: GitbayClient, repoPath: String) {
18 init(client: GitbayClient, repoPath: String, ref: String? = nil, path: String? = nil) {
1519 self.client = client
1620 self.repoPath = repoPath
21 self.ref = ref
22 self.path = path
1723 }
1824
1925 func load() async {
26 var argv = ["repo", "log", repoPath]
27 if let ref { argv.append(contentsOf: ["--ref", ref]) }
28 if let path { argv.append(contentsOf: ["--path", path]) }
2029 do {
21 let commits = try await client.readList(["repo", "log", repoPath], of: Commit.self)
30 let commits = try await client.readList(argv, of: Commit.self)
2231 state = commits.isEmpty ? .empty("No commits yet.") : .loaded(commits)
2332 } catch {
2433 state = .from(error)
gitbay/Views/Discovery/ExploreView.swift added +63
@@ -0,0 +1,63 @@
1import SwiftUI
2
3/// What this instance hosts. `repo search` needs a query; this is the
4/// listing you read when you do not know a name yet.
5struct ExploreView: View {
6
7 @State private var list: PagedListModel<PublicRepo>
8
9 init(client: GitbayClient) {
10 _list = State(initialValue: PagedListModel(
11 client: client,
12 argv: ["explore"],
13 emptyMessage: "This instance hosts no public repositories."
14 ))
15 }
16
17 var body: some View {
18 List {
19 ForEach(list.state.value ?? []) { repo in
20 NavigationLink(value: RepoRoute.repo(repo.path)) {
21 VStack(alignment: .leading, spacing: 4) {
22 HStack(spacing: 6) {
23 Text(repo.owner + "/")
24 .foregroundStyle(.secondary)
25 + Text(repo.name)
26 .fontWeight(.medium)
27 if repo.isArchived {
28 GBChip("archived", .secondary)
29 }
30 }
31 .font(.gbSans(.subheadline))
32 .lineLimit(1)
33
34 if let description = repo.description, !description.isEmpty {
35 Text(description)
36 .font(.gbSans(.caption))
37 .foregroundStyle(.secondary)
38 .lineLimit(2)
39 }
40 if let topics = repo.topics, !topics.isEmpty {
41 ScrollView(.horizontal, showsIndicators: false) {
42 HStack(spacing: 6) {
43 ForEach(topics, id: \.self) { topic in
44 GBChip(topic, .gbAccent)
45 }
46 }
47 }
48 }
49 }
50 .padding(.vertical, 2)
51 }
52 }
53 PageFooter(list: list)
54 }
55 .overlay { LoadStateOverlay(state: list.state) }
56 .navigationTitle("Explore")
57 .toolbar { AccountMenu() }
58 .task {
59 if list.state.value == nil { await list.reload() }
60 }
61 .refreshable { await list.reload() }
62 }
63}
gitbay/Views/Repos/FileView.swift +5
@@ -28,6 +28,11 @@ struct FileView: View {
2828 ToolbarItem(placement: .topBarTrailing) {
2929 if model.state.value?.binary == false {
3030 Menu {
31 NavigationLink(value: RepoRoute.log(
32 repo: model.repoPath, ref: model.ref, path: model.filePath
33 )) {
34 Label("History", systemImage: "clock")
35 }
3136 NavigationLink(value: RepoRoute.blame(
3237 repo: model.repoPath, path: model.filePath, ref: model.ref
3338 )) {
gitbay/Views/Repos/LogView.swift +14 −3
@@ -4,10 +4,21 @@ struct LogView: View {
44
55 @State private var model: LogViewModel
66
7 init(client: GitbayClient, repo: String) {
8 _model = State(initialValue: LogViewModel(client: client, repoPath: repo))
7 init(client: GitbayClient, repo: String, ref: String?, path: String?) {
8 _model = State(initialValue: LogViewModel(
9 client: client, repoPath: repo, ref: ref, path: path
10 ))
911 }
1012
13 /// Name what this history is of: a file, a ref, or the repository.
14 private var title: String {
15 if let path = model.path {
16 return String(path.split(separator: "/").last ?? "")
17 }
18 return model.ref.map { "History · \($0)" } ?? "History"
19 }
20
21
1122 var body: some View {
1223 List {
1324 ForEach(model.state.value ?? []) { commit in
@@ -17,7 +28,7 @@ struct LogView: View {
1728 }
1829 }
1930 .overlay { LoadStateOverlay(state: model.state) }
20 .navigationTitle("History")
31 .navigationTitle(title)
2132 .navigationBarTitleDisplayMode(.inline)
2233 .task { await model.load() }
2334 .refreshable { await model.load() }
gitbay/Views/Repos/RepoRoute.swift +1 −1
@@ -6,7 +6,7 @@ nonisolated enum RepoRoute: Hashable {
66 case repo(String)
77 case tree(repo: String, directory: String, ref: String?)
88 case file(repo: String, path: String, ref: String?)
9 case log(repo: String)
9 case log(repo: String, ref: String?, path: String?)
1010 case settings(repo: String)
1111 case grep(repo: String)
1212 case blame(repo: String, path: String, ref: String?)
gitbay/Views/Repos/RepoView.swift +1 −1
@@ -26,7 +26,7 @@ struct RepoView: View {
2626 NavigationLink(value: RepoRoute.tree(repo: path, directory: "", ref: nil)) {
2727 Label("Files", systemImage: "folder")
2828 }
29 NavigationLink(value: RepoRoute.log(repo: path)) {
29 NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) {
3030 Label("History", systemImage: "clock")
3131 }
3232 NavigationLink(value: RepoRoute.refs(repo: path)) {
gitbay/Views/Repos/TreeView.swift +10
@@ -37,6 +37,16 @@ struct TreeView: View {
3737 ? String(model.repoPath.split(separator: "/").last ?? "")
3838 : String(model.directory.split(separator: "/").last ?? ""))
3939 .navigationBarTitleDisplayMode(.inline)
40 .toolbar {
41 ToolbarItem(placement: .topBarTrailing) {
42 NavigationLink(value: RepoRoute.log(
43 repo: model.repoPath, ref: model.ref, path: nil
44 )) {
45 Image(systemName: "clock")
46 }
47 .accessibilityIdentifier("tree-history-button")
48 }
49 }
4050 .task { await model.load() }
4151 .refreshable { await model.load() }
4252 }
gitbayTests/ExploreRefLogTests.swift added +96
@@ -0,0 +1,96 @@
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 logJSON = """
16 {"protocol_version":1,"data":[\
17 {"sha":"aaaaaaaaaabbbbbbbbbb","subject":"only on side","author_name":"cmc",\
18 "author_email":"c@x.test","date":"2026-08-28T02:00:00Z",\
19 "signature":{"state":"unsigned"}}],"exit_code":0}
20 """
21
22@MainActor
23struct LogAtRefTests {
24
25 @Test func noRefReadsTheDefaultBranch() async throws {
26 let (client, stub) = try makeClient()
27 stub.enqueue(.init(status: 200, json: logJSON))
28 let model = LogViewModel(client: client, repoPath: "krz/gitbay")
29
30 await model.load()
31
32 #expect(stub.seen.first?.url.query() == "argv=repo&argv=log&argv=krz/gitbay")
33 }
34
35 @Test func aRefIsForwardedToTheCommand() async throws {
36 let (client, stub) = try makeClient()
37 stub.enqueue(.init(status: 200, json: logJSON))
38 let model = LogViewModel(client: client, repoPath: "krz/gitbay", ref: "side")
39
40 await model.load()
41
42 #expect(model.state.value?.first?.subject == "only on side")
43 #expect(stub.seen.first?.url.query() ==
44 "argv=repo&argv=log&argv=krz/gitbay&argv=--ref&argv=side")
45 }
46
47 @Test func anUnknownRefIsAnEmptyState() async throws {
48 let (client, stub) = try makeClient()
49 stub.enqueue(.init(status: 404, json:
50 #"{"protocol_version":1,"error":"no ref \"nope\" in krz/gitbay","exit_code":3}"#))
51 let model = LogViewModel(client: client, repoPath: "krz/gitbay", ref: "nope")
52
53 await model.load()
54
55 guard case .empty = model.state else {
56 Issue.record("expected .empty, got \(model.state)")
57 return
58 }
59 }
60}
61
62@MainActor
63struct ExploreTests {
64
65 @Test func exploreListsPublicReposAndPages() async throws {
66 let (client, stub) = try makeClient()
67 stub.enqueue(.init(status: 200, json: """
68 {"protocol_version":1,"data":{"items":[\
69 {"path":"krz/gitbay","description":"a forge","topics":["cli","forge"]},\
70 {"path":"krz/old","archived":true}],"next":"ZXhwbG9yZTpr"},"exit_code":0}
71 """))
72 stub.enqueue(.init(status: 200, json: """
73 {"protocol_version":1,"data":{"items":[{"path":"krz/space-wiki"}]},"exit_code":0}
74 """))
75 let list = PagedListModel<PublicRepo>(
76 client: client, argv: ["explore"], emptyMessage: "none")
77
78 await list.reload()
79 #expect(list.state.value?.map(\.path) == ["krz/gitbay", "krz/old"])
80 #expect(list.state.value?[0].topics == ["cli", "forge"])
81 #expect(list.state.value?[1].isArchived == true)
82 #expect(list.hasMore)
83
84 await list.loadMore()
85 #expect(list.state.value?.count == 3)
86 #expect(!list.hasMore)
87 #expect(stub.seen[1].url.query()?.contains("argv=--cursor&argv=ZXhwbG9yZTpr") == true)
88 }
89
90 @Test func ownerAndNameSplitForDisplay() {
91 let repo = PublicRepo(path: "audit-labs/audit-tools",
92 description: nil, archived: nil, topics: nil)
93 #expect(repo.owner == "audit-labs")
94 #expect(repo.name == "audit-tools")
95 }
96}
gitbayUITests/LiveSmokeUITests.swift +39
@@ -766,3 +766,42 @@ extension LiveSmokeUITests {
766766 app.buttons["None"].firstMatch.tap()
767767 }
768768 }
769
770extension LiveSmokeUITests {
771
772 /// Explore and history at a ref the two capabilities krz/gitbay!101
773 /// made reachable. Read-only.
774 func testExploreAndRefLogFlows() throws {
775 // --- explore lists what the instance hosts ---
776 selectTab("Explore")
777 let firstRepo = app.cells.firstMatch
778 XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
779 // Public repos this account does not own are reachable here.
780 XCTAssertTrue(app.staticTexts
781 .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch
782 .waitForExistence(timeout: 10), "explore is not the public listing")
783 firstRepo.tap()
784 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
785 "explore row did not open its repo")
786
787 // --- history at a ref ---
788 app.terminate()
789 app.launch()
790 openRepo("krz/gitbay")
791 // Browse a ref, then ask for its history the move the tree page
792 // offers on the web.
793 app.staticTexts["Branches & Tags"].firstMatch.tap()
794 let main = app.staticTexts["main"].firstMatch
795 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
796 main.tap()
797 let history = app.descendants(matching: .any)
798 .matching(identifier: "tree-history-button").firstMatch
799 XCTAssertTrue(history.waitForExistence(timeout: 20), "no History on the tree")
800 history.tap()
801 XCTAssertTrue(app.navigationBars
802 .containing(NSPredicate(format: "identifier CONTAINS 'main'")).firstMatch
803 .waitForExistence(timeout: 20), "ref history did not open")
804 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
805 "ref history rendered no commits")
806 }
807}