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

merged merged by cmc on 2026-08-28 18:27 UTC · krz/gitbay-ios:commit-diff-and-profile-tab into main

9 files changed, +39 −88

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