a native ios client for gitbay

client ios swift

https://gitbay.org

Commit 4328d08603

4328d08603df7160ccf87696f1c8746e6e1094ed

parent: 9ba104d780

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-28T18:26:06Z

commits: show the patch in place; move the profile to a tab

A Changes row navigated to the whole file at that commit while the patch
sat behind a separate toolbar button, so tapping a row labelled +12 −3
opened an unannotated source file. The commit screen now renders the
patch through DiffFileSection, the same component the MR diff uses, and
the web's commit page has always been. The toolbar button and the
commitDiff route existed only to reach that patch, so both are gone.

The profile is a tab rather than a menu entry, and the account control
lives there alone: dashboard and repositories keep their + button, feed
and explore carry nothing.

Also scrolls to the email section in testAccountFlows — a List does not
build rows it has not shown. That failure predates this branch.
gitbay/ContentView.swift +8 −2
@@ -31,6 +31,14 @@ struct ContentView: View {
3131 .navigationDestinations(client: client)
3232 }
3333 }
34 Tab("My Profile", systemImage: "person.crop.circle") {
35 NavigationStack {
36 ProfileView(client: client, name: account.username,
37 title: "My Profile")
38 .toolbar { AccountMenu() }
39 .navigationDestinations(client: client)
40 }
41 }
3442 }
3543 .id(account.id) // fresh screens on account switch
3644 } else {
@@ -96,8 +104,6 @@ private struct RouteDestinations: ViewModifier {
96104 WikiPageView(client: client, repo: repo, page: page)
97105 case .commit(let repo, let sha):
98106 CommitView(client: client, repo: repo, sha: sha)
99 case .commitDiff(let repo, let sha):
100 CommitDiffView(client: client, repo: repo, sha: sha)
101107 case .profile(let name):
102108 ProfileView(client: client, name: name)
103109 case .account:
gitbay/Views/Dashboard/DashboardView.swift −1
@@ -52,7 +52,6 @@ struct DashboardView: View {
5252 }
5353 .accessibilityIdentifier("dashboard-create-button")
5454 }
55 AccountMenu()
5655 }
5756 .sheet(isPresented: $composing) {
5857 RepoCreateSheet(
gitbay/Views/Discovery/ExploreView.swift −1
@@ -61,7 +61,6 @@ struct ExploreView: View {
6161 }
6262 .overlay { LoadStateOverlay(state: list.state) }
6363 .navigationTitle("Explore")
64 .toolbar { AccountMenu() }
6564 .task {
6665 if list.state.value == nil { await list.reload() }
6766 }
gitbay/Views/Discovery/FeedView.swift −1
@@ -22,7 +22,6 @@ struct FeedView: View {
2222 }
2323 .overlay { LoadStateOverlay(state: list.state) }
2424 .navigationTitle("Feed")
25 .toolbar { AccountMenu() }
2625 .task {
2726 if list.state.value == nil {
2827 await list.reload()
gitbay/Views/Discovery/ProfileView.swift +7 −2
@@ -6,8 +6,13 @@ struct ProfileView: View {
66
77 @State private var model: ProfileViewModel
88
9 init(client: GitbayClient, name: String) {
9 /// Someone else's profile is titled with their name. Your own is
10 /// reached from a tab and titled to match it.
11 private let title: String?
12
13 init(client: GitbayClient, name: String, title: String? = nil) {
1014 _model = State(initialValue: ProfileViewModel(client: client, name: name))
15 self.title = title
1116 }
1217
1318 var body: some View {
@@ -27,7 +32,7 @@ struct ProfileView: View {
2732 }
2833 }
2934 .overlay { LoadStateOverlay(state: model.state) }
30 .navigationTitle(model.name)
35 .navigationTitle(title ?? model.name)
3136 .navigationBarTitleDisplayMode(.inline)
3237 .task { await model.load() }
3338 .refreshable { await model.load() }
gitbay/Views/Repos/CommitView.swift +5 −59
@@ -64,73 +64,19 @@ struct CommitView: View {
6464 }
6565
6666 if let diff = model.diff, !diff.files.isEmpty {
67 Section("Changes +\(diff.additions)\(diff.deletions)") {
68 ForEach(diff.files) { file in
69 NavigationLink(value: RepoRoute.file(
70 repo: model.repoPath, path: file.displayPath, ref: commit.sha
71 )) {
72 HStack {
73 Text(file.displayPath)
74 .font(.gbMono(.caption))
75 .lineLimit(1)
76 .truncationMode(.head)
77 Spacer()
78 Text("+\(file.additions)").foregroundStyle(Color.gbOK)
79 Text("\(file.deletions)").foregroundStyle(Color.gbBad)
80 }
81 .font(.gbSans(.caption2))
82 }
83 }
84 }
85 }
86 }
87 }
88 .overlay { LoadStateOverlay(state: model.state) }
89 .navigationTitle(String(model.sha.prefix(10)))
90 .navigationBarTitleDisplayMode(.inline)
91 .toolbar {
92 ToolbarItem(placement: .topBarTrailing) {
93 if model.diff?.files.isEmpty == false {
94 NavigationLink(value: RepoRoute.commitDiff(
95 repo: model.repoPath, sha: model.sha
96 )) {
97 Image(systemName: "plus.forwardslash.minus")
98 }
99 .accessibilityIdentifier("commit-diff-button")
100 }
101 }
102 }
103 .task { await model.load() }
104 .refreshable { await model.load() }
105 }
106}
107
108/// The commit's patch, rendered by the same view the MR diff uses.
109struct CommitDiffView: View {
110
111 @State private var model: CommitDetailViewModel
112
113 init(client: GitbayClient, repo: String, sha: String) {
114 _model = State(initialValue: CommitDetailViewModel(
115 client: client, repoPath: repo, sha: sha
116 ))
117 }
118
119 var body: some View {
120 ZStack {
121 Color.clear
122 if let diff = model.diff, !diff.files.isEmpty {
123 List {
67 // The patch, in place. The web's commit page is the
68 // diff a file header expands its hunks rather than
69 // linking away to the whole file.
12470 ForEach(diff.files) { file in
12571 DiffFileSection(file: file)
12672 }
12773 }
128 .listStyle(.plain)
12974 }
13075 }
13176 .overlay { LoadStateOverlay(state: model.state) }
132 .navigationTitle("Diff")
77 .navigationTitle(String(model.sha.prefix(10)))
13378 .navigationBarTitleDisplayMode(.inline)
13479 .task { await model.load() }
80 .refreshable { await model.load() }
13581 }
13682 }
gitbay/Views/Repos/RepoListView.swift +2 −5
@@ -35,7 +35,6 @@ struct RepoListView: View {
3535 }
3636 .accessibilityIdentifier("repo-create-button")
3737 }
38 AccountMenu()
3938 }
4039 .sheet(isPresented: $composing) {
4140 RepoCreateSheet(
@@ -148,7 +147,8 @@ private struct RepoRow: View {
148147 }
149148 }
150149
151/// The account switcher and sign-out, on every top-level screen.
150/// The account switcher and sign-out. It lives on the My Profile tab
151/// only: the other tabs are about the instance, not about who you are.
152152 struct AccountMenu: ToolbarContent {
153153
154154 @Environment(SessionStore.self) private var session
@@ -158,9 +158,6 @@ struct AccountMenu: ToolbarContent {
158158 Menu {
159159 if let current = session.current {
160160 Section(current.label) {
161 NavigationLink(value: RepoRoute.profile(current.username)) {
162 Label("My Profile", systemImage: "person.crop.circle")
163 }
164161 NavigationLink(value: RepoRoute.account) {
165162 Label("Keys & Email", systemImage: "key")
166163 }
gitbay/Views/Repos/RepoRoute.swift −1
@@ -15,7 +15,6 @@ nonisolated enum RepoRoute: Hashable {
1515 case wiki(repo: String)
1616 case wikiPage(repo: String, page: String)
1717 case commit(repo: String, sha: String)
18 case commitDiff(repo: String, sha: String)
1918 case profile(String)
2019 case account
2120 case addAccount
gitbayUITests/LiveSmokeUITests.swift +17 −16
@@ -182,9 +182,10 @@ final class LiveSmokeUITests: XCTestCase {
182182 app.navigationBars.buttons.firstMatch.tap()
183183 }
184184
185 /// Keys, PGP and email live behind the profile menu now, not their
186 /// own toolbar button.
185 /// Keys, PGP and email live behind the account menu, which is on the
186 /// My Profile tab and nowhere else.
187187 func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) {
188 selectTab("My Profile")
188189 let menu = app.descendants(matching: .any)
189190 .matching(identifier: "account-menu").firstMatch
190191 XCTAssertTrue(menu.waitForExistence(timeout: 15),
@@ -512,7 +513,9 @@ extension LiveSmokeUITests {
512513 // A bogus verification code is refused, not swallowed.
513514 let code = app.descendants(matching: .any)
514515 .matching(identifier: "email-code").firstMatch
515 XCTAssertTrue(code.waitForExistence(timeout: 5))
516 // Email sits below the keys on a List, which does not build rows
517 // it has not shown.
518 XCTAssertTrue(scrollTo(code), "email section not reachable")
516519 focusAndType(code, "000000")
517520 let verify = app.descendants(matching: .any)
518521 .matching(identifier: "email-verify").firstMatch
@@ -713,16 +716,16 @@ extension LiveSmokeUITests {
713716 "dashboard + did not open the create sheet")
714717 app.buttons["Cancel"].firstMatch.tap()
715718
716 // --- identity is one menu: profile and keys together ---
719 // --- identity is its own tab; the account menu rides with it ---
720 selectTab("My Profile")
717721 let menu = app.descendants(matching: .any)
718722 .matching(identifier: "account-menu").firstMatch
719 XCTAssertTrue(menu.waitForExistence(timeout: 10))
723 XCTAssertTrue(menu.waitForExistence(timeout: 10),
724 "account menu missing from My Profile")
720725 menu.tap()
721726 XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
722727 "keys not in the account menu")
723 let myProfile = app.buttons["My Profile"].firstMatch
724 XCTAssertTrue(myProfile.exists, "profile not in the account menu")
725 myProfile.tap()
728 app.tap() // dismiss the menu; the profile is already on screen
726729
727730 // --- a profile is a profile: description, links, orgs, graph, repos ---
728731 XCTAssertTrue(app.staticTexts
@@ -746,14 +749,12 @@ extension LiveSmokeUITests {
746749 let firstCommit = app.cells.firstMatch
747750 XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
748751 firstCommit.tap()
749 // The commit screen shows its changed files and can open the patch.
750 XCTAssertTrue(app.descendants(matching: .any)
751 .matching(identifier: "commit-diff-button").firstMatch
752 .waitForExistence(timeout: 20), "commit did not open")
753 app.descendants(matching: .any).matching(identifier: "commit-diff-button")
754 .firstMatch.tap()
755 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
756 "commit diff rendered nothing")
752 // The commit screen IS the patch: no navigating away to find it.
753 // A hunk header (@@) only appears in a rendered diff.
754 XCTAssertTrue(app.staticTexts
755 .containing(NSPredicate(format: "label BEGINSWITH '@@'")).firstMatch
756 .waitForExistence(timeout: 20),
757 "commit screen did not render the patch inline")
757758 }
758759 }
759760