Commit 8bc7411fd3

8bc7411fd3bb6343d5c379d7c53e542ddd72458f

parent: 41aee8a8b4

Unsigned

cmc <hello@cleberg.net> · 2026-08-28 18:26 UTC

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.

Layout: unified · split

gitbay/ContentView.swift +8 −2
@@ -31,6 +31,14 @@ struct ContentView: View {
31 .navigationDestinations(client: client) 31 .navigationDestinations(client: client)
32 } 32 }
33 } 33 }
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 }
34 } 42 }
35 .id(account.id) // fresh screens on account switch 43 .id(account.id) // fresh screens on account switch
36 } else { 44 } else {
@@ -96,8 +104,6 @@ private struct RouteDestinations: ViewModifier {
96 WikiPageView(client: client, repo: repo, page: page) 104 WikiPageView(client: client, repo: repo, page: page)
97 case .commit(let repo, let sha): 105 case .commit(let repo, let sha):
98 CommitView(client: client, repo: repo, sha: sha) 106 CommitView(client: client, repo: repo, sha: sha)
99 case .commitDiff(let repo, let sha):
100 CommitDiffView(client: client, repo: repo, sha: sha)
101 case .profile(let name): 107 case .profile(let name):
102 ProfileView(client: client, name: name) 108 ProfileView(client: client, name: name)
103 case .account: 109 case .account:
gitbay/Views/Dashboard/DashboardView.swift −1
@@ -52,7 +52,6 @@ struct DashboardView: View {
52 } 52 }
53 .accessibilityIdentifier("dashboard-create-button") 53 .accessibilityIdentifier("dashboard-create-button")
54 } 54 }
55 AccountMenu()
56 } 55 }
57 .sheet(isPresented: $composing) { 56 .sheet(isPresented: $composing) {
58 RepoCreateSheet( 57 RepoCreateSheet(
gitbay/Views/Discovery/ExploreView.swift −1
@@ -61,7 +61,6 @@ struct ExploreView: View {
61 } 61 }
62 .overlay { LoadStateOverlay(state: list.state) } 62 .overlay { LoadStateOverlay(state: list.state) }
63 .navigationTitle("Explore") 63 .navigationTitle("Explore")
64 .toolbar { AccountMenu() }
65 .task { 64 .task {
66 if list.state.value == nil { await list.reload() } 65 if list.state.value == nil { await list.reload() }
67 } 66 }
gitbay/Views/Discovery/FeedView.swift −1
@@ -22,7 +22,6 @@ struct FeedView: View {
22 } 22 }
23 .overlay { LoadStateOverlay(state: list.state) } 23 .overlay { LoadStateOverlay(state: list.state) }
24 .navigationTitle("Feed") 24 .navigationTitle("Feed")
25 .toolbar { AccountMenu() }
26 .task { 25 .task {
27 if list.state.value == nil { 26 if list.state.value == nil {
28 await list.reload() 27 await list.reload()
gitbay/Views/Discovery/ProfileView.swift +7 −2
@@ -6,8 +6,13 @@ struct ProfileView: View {
6 6
7 @State private var model: ProfileViewModel 7 @State private var model: ProfileViewModel
8 8
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) {
10 _model = State(initialValue: ProfileViewModel(client: client, name: name)) 14 _model = State(initialValue: ProfileViewModel(client: client, name: name))
15 self.title = title
11 } 16 }
12 17
13 var body: some View { 18 var body: some View {
@@ -27,7 +32,7 @@ struct ProfileView: View {
27 } 32 }
28 } 33 }
29 .overlay { LoadStateOverlay(state: model.state) } 34 .overlay { LoadStateOverlay(state: model.state) }
30 .navigationTitle(model.name) 35 .navigationTitle(title ?? model.name)
31 .navigationBarTitleDisplayMode(.inline) 36 .navigationBarTitleDisplayMode(.inline)
32 .task { await model.load() } 37 .task { await model.load() }
33 .refreshable { await model.load() } 38 .refreshable { await model.load() }
gitbay/Views/Repos/CommitView.swift +5 −59
@@ -64,73 +64,19 @@ struct CommitView: View {
64 } 64 }
65 65
66 if let diff = model.diff, !diff.files.isEmpty { 66 if let diff = model.diff, !diff.files.isEmpty {
67 Section("Changes +\(diff.additions) −\(diff.deletions)") { 67 // The patch, in place. The web's commit page is the
68 ForEach(diff.files) { file in 68 // diff — a file header expands its hunks rather than
69 NavigationLink(value: RepoRoute.file( 69 // linking away to the whole 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 {
124 ForEach(diff.files) { file in 70 ForEach(diff.files) { file in
125 DiffFileSection(file: file) 71 DiffFileSection(file: file)
126 } 72 }
127 } 73 }
128 .listStyle(.plain)
129 } 74 }
130 } 75 }
131 .overlay { LoadStateOverlay(state: model.state) } 76 .overlay { LoadStateOverlay(state: model.state) }
132 .navigationTitle("Diff") 77 .navigationTitle(String(model.sha.prefix(10)))
133 .navigationBarTitleDisplayMode(.inline) 78 .navigationBarTitleDisplayMode(.inline)
134 .task { await model.load() } 79 .task { await model.load() }
80 .refreshable { await model.load() }
135 } 81 }
136} 82}
gitbay/Views/Repos/RepoListView.swift +2 −5
@@ -35,7 +35,6 @@ struct RepoListView: View {
35 } 35 }
36 .accessibilityIdentifier("repo-create-button") 36 .accessibilityIdentifier("repo-create-button")
37 } 37 }
38 AccountMenu()
39 } 38 }
40 .sheet(isPresented: $composing) { 39 .sheet(isPresented: $composing) {
41 RepoCreateSheet( 40 RepoCreateSheet(
@@ -148,7 +147,8 @@ private struct RepoRow: View {
148 } 147 }
149} 148}
150 149
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.
152struct AccountMenu: ToolbarContent { 152struct AccountMenu: ToolbarContent {
153 153
154 @Environment(SessionStore.self) private var session 154 @Environment(SessionStore.self) private var session
@@ -158,9 +158,6 @@ struct AccountMenu: ToolbarContent {
158 Menu { 158 Menu {
159 if let current = session.current { 159 if let current = session.current {
160 Section(current.label) { 160 Section(current.label) {
161 NavigationLink(value: RepoRoute.profile(current.username)) {
162 Label("My Profile", systemImage: "person.crop.circle")
163 }
164 NavigationLink(value: RepoRoute.account) { 161 NavigationLink(value: RepoRoute.account) {
165 Label("Keys & Email", systemImage: "key") 162 Label("Keys & Email", systemImage: "key")
166 } 163 }
gitbay/Views/Repos/RepoRoute.swift −1
@@ -15,7 +15,6 @@ nonisolated enum RepoRoute: Hashable {
15 case wiki(repo: String) 15 case wiki(repo: String)
16 case wikiPage(repo: String, page: String) 16 case wikiPage(repo: String, page: String)
17 case commit(repo: String, sha: String) 17 case commit(repo: String, sha: String)
18 case commitDiff(repo: String, sha: String)
19 case profile(String) 18 case profile(String)
20 case account 19 case account
21 case addAccount 20 case addAccount
gitbayUITests/LiveSmokeUITests.swift +17 −16
@@ -182,9 +182,10 @@ final class LiveSmokeUITests: XCTestCase {
182 app.navigationBars.buttons.firstMatch.tap() 182 app.navigationBars.buttons.firstMatch.tap()
183 } 183 }
184 184
185 /// Keys, PGP and email live behind the profile menu now, not their 185 /// Keys, PGP and email live behind the account menu, which is on the
186 /// own toolbar button. 186 /// My Profile tab and nowhere else.
187 func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) { 187 func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) {
188 selectTab("My Profile")
188 let menu = app.descendants(matching: .any) 189 let menu = app.descendants(matching: .any)
189 .matching(identifier: "account-menu").firstMatch 190 .matching(identifier: "account-menu").firstMatch
190 XCTAssertTrue(menu.waitForExistence(timeout: 15), 191 XCTAssertTrue(menu.waitForExistence(timeout: 15),
@@ -512,7 +513,9 @@ extension LiveSmokeUITests {
512 // A bogus verification code is refused, not swallowed. 513 // A bogus verification code is refused, not swallowed.
513 let code = app.descendants(matching: .any) 514 let code = app.descendants(matching: .any)
514 .matching(identifier: "email-code").firstMatch 515 .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")
516 focusAndType(code, "000000") 519 focusAndType(code, "000000")
517 let verify = app.descendants(matching: .any) 520 let verify = app.descendants(matching: .any)
518 .matching(identifier: "email-verify").firstMatch 521 .matching(identifier: "email-verify").firstMatch
@@ -713,16 +716,16 @@ extension LiveSmokeUITests {
713 "dashboard + did not open the create sheet") 716 "dashboard + did not open the create sheet")
714 app.buttons["Cancel"].firstMatch.tap() 717 app.buttons["Cancel"].firstMatch.tap()
715 718
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")
717 let menu = app.descendants(matching: .any) 721 let menu = app.descendants(matching: .any)
718 .matching(identifier: "account-menu").firstMatch 722 .matching(identifier: "account-menu").firstMatch
719 XCTAssertTrue(menu.waitForExistence(timeout: 10)) 723 XCTAssertTrue(menu.waitForExistence(timeout: 10),
724 "account menu missing from My Profile")
720 menu.tap() 725 menu.tap()
721 XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5), 726 XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
722 "keys not in the account menu") 727 "keys not in the account menu")
723 let myProfile = app.buttons["My Profile"].firstMatch 728 app.tap() // dismiss the menu; the profile is already on screen
724 XCTAssertTrue(myProfile.exists, "profile not in the account menu")
725 myProfile.tap()
726 729
727 // --- a profile is a profile: description, links, orgs, graph, repos --- 730 // --- a profile is a profile: description, links, orgs, graph, repos ---
728 XCTAssertTrue(app.staticTexts 731 XCTAssertTrue(app.staticTexts
@@ -746,14 +749,12 @@ extension LiveSmokeUITests {
746 let firstCommit = app.cells.firstMatch 749 let firstCommit = app.cells.firstMatch
747 XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty") 750 XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
748 firstCommit.tap() 751 firstCommit.tap()
749 // The commit screen shows its changed files and can open the patch. 752 // The commit screen IS the patch: no navigating away to find it.
750 XCTAssertTrue(app.descendants(matching: .any) 753 // A hunk header (@@) only appears in a rendered diff.
751 .matching(identifier: "commit-diff-button").firstMatch 754 XCTAssertTrue(app.staticTexts
752 .waitForExistence(timeout: 20), "commit did not open") 755 .containing(NSPredicate(format: "label BEGINSWITH '@@'")).firstMatch
753 app.descendants(matching: .any).matching(identifier: "commit-diff-button") 756 .waitForExistence(timeout: 20),
754 .firstMatch.tap() 757 "commit screen did not render the patch inline")
755 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
756 "commit diff rendered nothing")
757 } 758 }
758} 759}
759 760