a native ios client for gitbay

client ios swift

https://gitbay.org

Commit 4a31811ff2

4a31811ff230b6212555cc13219fdc45f8d3ac08

parent: 8046dd0f50

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-28T02:55:18Z

profile page, one identity menu, dashboard create, tappable history

Four things, three of them navigation and one a screen that was barely
a screen.

Identity lives in one place now. The key icon was a second entry point
to the same account, so it folded into the profile menu: My Profile,
Keys & Email, switch account, add, sign out. The dashboard takes the
freed slot for +, which creates a repository — the action the web's
rail offers from every page.

The profile page is a profile: description, website, the orgs a user
belongs to (or an org's members, each openable), a year of activity as
a contribution graph, and every repository the account can see. All of
it is one profile show read now that krz/gitbay!100 exposes it; the
old screen guessed at repositories by filtering a search, which found
the wrong ones and could not show the rest at all.

History rows open their commit — subject, body, signature verdict,
checks, and the changed files, with the patch behind them. That needed
repo commit upstream: the web read commits straight from git, so there
was nothing for a row to navigate to. The diff file section is shared
with the MR diff now, with review threads optional since a commit has
none.

162 unit tests. Live smoke green against the deployed server for all
four: dashboard +, the merged menu, the profile with orgs and graph
and repos, and a log entry opening its commit and patch.

Ref #11
gitbay/ContentView.swift +4
@@ -80,6 +80,10 @@ private struct RouteDestinations: ViewModifier {
8080 GrepView(client: client, repo: repo)
8181 case .blame(let repo, let path, let ref):
8282 BlameView(client: client, repo: repo, path: path, ref: ref)
83 case .commit(let repo, let sha):
84 CommitView(client: client, repo: repo, sha: sha)
85 case .commitDiff(let repo, let sha):
86 CommitDiffView(client: client, repo: repo, sha: sha)
8387 case .profile(let name):
8488 ProfileView(client: client, name: name)
8589 case .account:
gitbay/Discovery/ProfileViewModel.swift +40 −8
@@ -1,8 +1,9 @@
11 import Foundation
22 import Observation
33
4/// `profile show <name>` plus the profile's reachable repositories via
5/// `repo search` filtered to the owner prefix.
4/// `profile show <name>` the whole profile in one read: description,
5/// links, org membership (or an org's members), the repositories the
6/// account may see, and a year of activity.
67 @Observable
78 @MainActor
89 final class ProfileViewModel {
@@ -12,10 +13,46 @@ final class ProfileViewModel {
1213 let kind: String // user | org
1314 let description: String?
1415 let website: String?
16 let orgs: [Member]?
17 let members: [Member]?
18 let repos: [Repo]
19 let activity: [Day]?
20 let activityTotal: Int
21
22 enum CodingKeys: String, CodingKey {
23 case name, kind, description, website, orgs, members, repos, activity
24 case activityTotal = "activity_total"
25 }
26
27 var isOrg: Bool { kind == "org" }
28
29 nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable {
30 let name: String
31 let role: String?
32 var id: String { name }
33 }
34
35 nonisolated struct Repo: Decodable, Sendable, Hashable, Identifiable {
36 let path: String
37 let visibility: String
38 let description: String?
39 let archived: Bool?
40
41 var id: String { path }
42 var isPrivate: Bool { visibility == "private" }
43 var isArchived: Bool { archived ?? false }
44 var name: String { String(path.split(separator: "/").last ?? "") }
45 }
46
47 /// One day's contributions. Days with none are omitted, so the
48 /// calendar is filled in by the view.
49 nonisolated struct Day: Decodable, Sendable, Hashable {
50 let date: String
51 let count: Int
52 }
1553 }
1654
1755 private(set) var state: LoadState<Profile> = .loading
18 private(set) var repos: [RepoSummary] = []
1956
2057 private let client: GitbayClient
2158 let name: String
@@ -30,11 +67,6 @@ final class ProfileViewModel {
3067 state = .loaded(try await client.read(["profile", "show", name], as: Profile.self))
3168 } catch {
3269 state = .from(error)
33 return
3470 }
35 // Search matches on path, so the owner name surfaces their repos;
36 // keep only true prefix matches.
37 repos = ((try? await client.readList(["repo", "search", name], of: RepoSummary.self)) ?? [])
38 .filter { $0.owner == name }
3971 }
4072 }
gitbay/Repos/CommitDetailViewModel.swift added +64
@@ -0,0 +1,64 @@
1import Foundation
2import Observation
3
4/// `repo commit` one commit with its patch, signature verdict and
5/// checks.
6nonisolated struct CommitDetail: Decodable, Sendable, Hashable {
7 let path: String
8 let sha: String
9 let subject: String
10 let message: String?
11 let authorName: String
12 let authorEmail: String
13 let committerEmail: String?
14 let date: Date
15 let signature: Commit.Signature
16 let checks: [Check]?
17 /// The unified patch, parsed client-side exactly like `mr diff`.
18 let diff: String
19
20 enum CodingKeys: String, CodingKey {
21 case path, sha, subject, message, date, signature, checks, diff
22 case authorName = "author_name"
23 case authorEmail = "author_email"
24 case committerEmail = "committer_email"
25 }
26
27 var shortSHA: String { String(sha.prefix(10)) }
28
29 nonisolated struct Check: Decodable, Sendable, Hashable, Identifiable {
30 let context: String
31 let state: String
32 let url: String?
33 var id: String { context }
34 }
35}
36
37@Observable
38@MainActor
39final class CommitDetailViewModel {
40
41 private(set) var state: LoadState<CommitDetail> = .loading
42 private(set) var diff: UnifiedDiff?
43
44 private let client: GitbayClient
45 let repoPath: String
46 let sha: String
47
48 init(client: GitbayClient, repoPath: String, sha: String) {
49 self.client = client
50 self.repoPath = repoPath
51 self.sha = sha
52 }
53
54 func load() async {
55 do {
56 let commit = try await client.read(
57 ["repo", "commit", repoPath, sha], as: CommitDetail.self)
58 diff = UnifiedDiff.parse(commit.diff)
59 state = .loaded(commit)
60 } catch {
61 state = .from(error)
62 }
63 }
64}
gitbay/Views/Dashboard/DashboardView.swift +18 −3
@@ -4,10 +4,14 @@ import SwiftUI
44 /// like the logged-in web dashboard.
55 struct DashboardView: View {
66
7 @Environment(SessionStore.self) private var session
78 @State private var model: DashboardViewModel
9 @State private var createModel: RepoCreateViewModel
10 @State private var composing = false
811
912 init(client: GitbayClient) {
1013 _model = State(initialValue: DashboardViewModel(client: client))
14 _createModel = State(initialValue: RepoCreateViewModel(client: client))
1115 }
1216
1317 var body: some View {
@@ -41,13 +45,24 @@ struct DashboardView: View {
4145 .navigationTitle("Dashboard")
4246 .toolbar {
4347 ToolbarItem(placement: .topBarTrailing) {
44 NavigationLink(value: RepoRoute.account) {
45 Image(systemName: "key")
48 Button {
49 composing = true
50 } label: {
51 Image(systemName: "plus")
4652 }
47 .accessibilityIdentifier("account-screen-link")
53 .accessibilityIdentifier("dashboard-create-button")
4854 }
4955 AccountMenu()
5056 }
57 .sheet(isPresented: $composing) {
58 RepoCreateSheet(
59 model: createModel,
60 ownerPrefix: session.current?.username ?? ""
61 ) {
62 composing = false
63 Task { await model.load() }
64 }
65 }
5166 .task { await model.load() }
5267 .refreshable { await model.load() }
5368 }
gitbay/Views/Discovery/ActivityGraph.swift added +98
@@ -0,0 +1,98 @@
1import SwiftUI
2
3/// The contribution calendar: 53 weeks of columns, Sunday at the top,
4/// ending on the current week the same grid the web draws.
5struct ActivityGraph: View {
6
7 let days: [ProfileViewModel.Profile.Day]
8
9 private static let calendar: Calendar = {
10 var calendar = Calendar(identifier: .gregorian)
11 calendar.timeZone = TimeZone(identifier: "UTC")!
12 return calendar
13 }()
14
15 private static let formatter: DateFormatter = {
16 let formatter = DateFormatter()
17 formatter.calendar = calendar
18 formatter.timeZone = calendar.timeZone
19 formatter.dateFormat = "yyyy-MM-dd"
20 return formatter
21 }()
22
23 /// Weeks of 7 days; nil is a day outside the window (before the start
24 /// or after today), drawn as empty space rather than a zero cell.
25 private var weeks: [[Cell?]] {
26 let counts = Dictionary(days.map { ($0.date, $0.count) }, uniquingKeysWith: +)
27 let today = Self.calendar.startOfDay(for: Date())
28 // End on the Saturday of the current week, matching the server's
29 // window so both surfaces show the same year.
30 let weekday = Self.calendar.component(.weekday, from: today) // 1 = Sunday
31 guard let end = Self.calendar.date(byAdding: .day, value: 7 - weekday, to: today),
32 let start = Self.calendar.date(byAdding: .day, value: -53 * 7 + 1, to: end) else {
33 return []
34 }
35
36 var result: [[Cell?]] = []
37 var cursor = start
38 while cursor <= end {
39 var week: [Cell?] = []
40 for _ in 0..<7 {
41 if cursor > today {
42 week.append(nil)
43 } else {
44 let key = Self.formatter.string(from: cursor)
45 week.append(Cell(date: key, count: counts[key] ?? 0))
46 }
47 cursor = Self.calendar.date(byAdding: .day, value: 1, to: cursor) ?? cursor
48 }
49 result.append(week)
50 }
51 return result
52 }
53
54 private struct Cell: Hashable {
55 let date: String
56 let count: Int
57
58 /// Five buckets, as the stylesheet colors them.
59 var level: Int {
60 switch count {
61 case 0: 0
62 case 1...2: 1
63 case 3...5: 2
64 case 6...9: 3
65 default: 4
66 }
67 }
68 }
69
70 var body: some View {
71 ScrollView(.horizontal, showsIndicators: false) {
72 HStack(alignment: .top, spacing: 3) {
73 ForEach(Array(weeks.enumerated()), id: \.offset) { _, week in
74 VStack(spacing: 3) {
75 ForEach(Array(week.enumerated()), id: \.offset) { _, cell in
76 RoundedRectangle(cornerRadius: 2)
77 .fill(color(for: cell))
78 .frame(width: 10, height: 10)
79 }
80 }
81 }
82 }
83 .padding(.vertical, 4)
84 }
85 .defaultScrollAnchor(.trailing)
86 }
87
88 private func color(for cell: Cell?) -> Color {
89 guard let cell else { return .clear }
90 return switch cell.level {
91 case 0: Color.gbFillSubtle
92 case 1: Color.gbOK.opacity(0.30)
93 case 2: Color.gbOK.opacity(0.55)
94 case 3: Color.gbOK.opacity(0.78)
95 default: Color.gbOK
96 }
97 }
98}
gitbay/Views/Discovery/ProfileView.swift +108 −48
@@ -1,6 +1,7 @@
11 import SwiftUI
22
3/// A user or organization: `profile show` plus their reachable repos.
3/// A user or organization: who they are, who they work with, what they
4/// have been doing, and what they own that you can see.
45 struct ProfileView: View {
56
67 @State private var model: ProfileViewModel
@@ -12,67 +13,126 @@ struct ProfileView: View {
1213 var body: some View {
1314 List {
1415 if let profile = model.state.value {
15 Section {
16 VStack(alignment: .leading, spacing: 6) {
17 HStack(spacing: 8) {
18 Image(systemName: profile.kind == "org"
19 ? "building.2" : "person.crop.circle")
20 .font(.gbSans(.title2))
21 .foregroundStyle(.secondary)
22 Text(profile.name)
23 .font(.gbSans(.title3).weight(.semibold))
24 Text(profile.kind)
25 .font(.gbSans(.caption2))
26 .padding(.horizontal, 6)
27 .padding(.vertical, 1)
28 .background(.quaternary, in: gbChipShape)
29 }
30 if let description = profile.description, !description.isEmpty {
31 Text(description)
32 .font(.gbSans(.subheadline))
33 .foregroundStyle(.secondary)
34 }
35 if let website = profile.website, let url = URL(string: website) {
36 Link(website, destination: url)
37 .font(.gbSans(.caption))
38 .lineLimit(1)
16 header(profile)
17 peopleSection(profile)
18 activitySection(profile)
19 reposSection(profile)
20 if profile.isOrg {
21 Section {
22 NavigationLink(value: OrgRoute.org(profile.name)) {
23 Label("Members & Teams", systemImage: "person.3")
3924 }
4025 }
41 .padding(.vertical, 4)
4226 }
27 }
28 }
29 .overlay { LoadStateOverlay(state: model.state) }
30 .navigationTitle(model.name)
31 .navigationBarTitleDisplayMode(.inline)
32 .task { await model.load() }
33 .refreshable { await model.load() }
34 }
4335
44 if profile.kind == "org" {
45 Section {
46 NavigationLink(value: OrgRoute.org(profile.name)) {
47 Label("Members & Teams", systemImage: "person.3")
36 // MARK: - Sections
37
38 private func header(_ profile: ProfileViewModel.Profile) -> some View {
39 Section {
40 VStack(alignment: .leading, spacing: 8) {
41 HStack(spacing: 8) {
42 Image(systemName: profile.isOrg ? "building.2" : "person.crop.circle")
43 .font(.title2)
44 .foregroundStyle(Color.gbAccent)
45 Text(profile.name)
46 .font(.gbSans(.title3).weight(.semibold))
47 GBChip(profile.kind, .secondary)
48 }
49 if let description = profile.description, !description.isEmpty {
50 Text(description)
51 .font(.gbSans(.subheadline))
52 .foregroundStyle(.secondary)
53 }
54 if let website = profile.website, !website.isEmpty,
55 let url = URL(string: website) {
56 Link(destination: url) {
57 Label(website, systemImage: "link")
58 .font(.gbSans(.caption))
59 .lineLimit(1)
60 }
61 }
62 }
63 .padding(.vertical, 4)
64 }
65 }
66
67 /// A user's organizations, or an organization's members either way,
68 /// who they work with, each one openable.
69 @ViewBuilder
70 private func peopleSection(_ profile: ProfileViewModel.Profile) -> some View {
71 let people = profile.isOrg ? (profile.members ?? []) : (profile.orgs ?? [])
72 if !people.isEmpty {
73 Section(profile.isOrg ? "Members" : "Organizations") {
74 ForEach(people) { person in
75 NavigationLink(value: RepoRoute.profile(person.name)) {
76 HStack {
77 Label(person.name, systemImage: profile.isOrg
78 ? "person.crop.circle" : "building.2")
79 .font(.gbSans(.subheadline))
80 Spacer()
81 if let role = person.role, !role.isEmpty {
82 GBChip(role, .secondary)
83 }
4884 }
4985 }
5086 }
87 }
88 }
89 }
5190
52 if !model.repos.isEmpty {
53 Section("Repositories") {
54 ForEach(model.repos) { repo in
55 NavigationLink(value: RepoRoute.repo(repo.path)) {
56 VStack(alignment: .leading, spacing: 2) {
57 Text(repo.name)
58 .font(.gbSans(.subheadline).weight(.medium))
59 if let description = repo.description, !description.isEmpty {
60 Text(description)
61 .font(.gbSans(.caption))
62 .foregroundStyle(.secondary)
63 .lineLimit(1)
64 }
91 @ViewBuilder
92 private func activitySection(_ profile: ProfileViewModel.Profile) -> some View {
93 if let activity = profile.activity, !activity.isEmpty {
94 Section {
95 ActivityGraph(days: activity)
96 } header: {
97 Text("Activity")
98 } footer: {
99 Text("\(profile.activityTotal) contributions in the last year.")
100 }
101 }
102 }
103
104 private func reposSection(_ profile: ProfileViewModel.Profile) -> some View {
105 Section("Repositories \(profile.repos.count)") {
106 if profile.repos.isEmpty {
107 Text("No visible repositories.")
108 .font(.gbSans(.subheadline))
109 .foregroundStyle(.secondary)
110 } else {
111 ForEach(profile.repos) { repo in
112 NavigationLink(value: RepoRoute.repo(repo.path)) {
113 VStack(alignment: .leading, spacing: 3) {
114 HStack(spacing: 6) {
115 Text(repo.name)
116 .font(.gbSans(.subheadline).weight(.medium))
117 if repo.isPrivate {
118 Image(systemName: "lock.fill")
119 .font(.gbSans(.caption2))
120 .foregroundStyle(.secondary)
65121 }
122 if repo.isArchived {
123 GBChip("archived", .secondary)
124 }
125 }
126 if let description = repo.description, !description.isEmpty {
127 Text(description)
128 .font(.gbSans(.caption))
129 .foregroundStyle(.secondary)
130 .lineLimit(2)
66131 }
67132 }
68133 }
69134 }
70135 }
71136 }
72 .overlay { LoadStateOverlay(state: model.state) }
73 .navigationTitle(model.name)
74 .navigationBarTitleDisplayMode(.inline)
75 .task { await model.load() }
76 .refreshable { await model.load() }
77137 }
78138 }
gitbay/Views/MRs/DiffView.swift +12 −9
@@ -18,7 +18,7 @@ struct DiffView: View {
1818 if let diff = model.diff, !diff.files.isEmpty {
1919 List {
2020 ForEach(diff.files) { file in
21 FileDiffSection(file: file, model: model)
21 DiffFileSection(file: file, model: model)
2222 }
2323 let detached = model.threads.filter { thread in
2424 thread.stale || !diff.anchors(thread)
@@ -46,10 +46,11 @@ struct DiffView: View {
4646 }
4747 }
4848
49private struct FileDiffSection: View {
49struct DiffFileSection: View {
5050
5151 let file: UnifiedDiff.File
52 let model: MRDetailViewModel
52 /// Review threads belong to merge requests; a commit diff has none.
53 var model: MRDetailViewModel? = nil
5354 @State private var collapsed = false
5455
5556 var body: some View {
@@ -97,7 +98,7 @@ private struct HunkView: View {
9798
9899 let file: UnifiedDiff.File
99100 let hunk: UnifiedDiff.Hunk
100 let model: MRDetailViewModel
101 let model: MRDetailViewModel?
101102
102103 var body: some View {
103104 ScrollView(.horizontal) {
@@ -110,11 +111,13 @@ private struct HunkView: View {
110111 .background(Color.gbFillSubtle)
111112 ForEach(hunk.lines) { line in
112113 LineView(line: line)
113 ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in
114 ReviewThreadView(thread: thread, model: model)
115 .padding(.vertical, 6)
116 .padding(.horizontal, 8)
117 .background(Color.gbFillSubtle)
114 if let model {
115 ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in
116 ReviewThreadView(thread: thread, model: model)
117 .padding(.vertical, 6)
118 .padding(.horizontal, 8)
119 .background(Color.gbFillSubtle)
120 }
118121 }
119122 }
120123 }
gitbay/Views/Repos/CommitView.swift added +136
@@ -0,0 +1,136 @@
1import SwiftUI
2
3/// One commit: what it says, who signed it, whether it passed, and what
4/// it changed.
5struct CommitView: View {
6
7 @State private var model: CommitDetailViewModel
8
9 init(client: GitbayClient, repo: String, sha: String) {
10 _model = State(initialValue: CommitDetailViewModel(
11 client: client, repoPath: repo, sha: sha
12 ))
13 }
14
15 var body: some View {
16 List {
17 if let commit = model.state.value {
18 Section {
19 VStack(alignment: .leading, spacing: 6) {
20 Text(commit.subject)
21 .font(.gbSans(.headline))
22 if let message = commit.message,
23 !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
24 Text(message.trimmingCharacters(in: .whitespacesAndNewlines))
25 .font(.gbSans(.subheadline))
26 .foregroundStyle(.secondary)
27 }
28 HStack(spacing: 6) {
29 Text(commit.shortSHA)
30 .font(.gbMono(.caption))
31 .foregroundStyle(.secondary)
32 SignatureBadge(signature: commit.signature)
33 }
34 HStack(spacing: 6) {
35 Text(commit.authorName)
36 Text(commit.date, format: .dateTime.year().month().day())
37 .foregroundStyle(.tertiary)
38 }
39 .font(.gbSans(.caption))
40 .foregroundStyle(.secondary)
41 }
42 .padding(.vertical, 2)
43 }
44
45 if let checks = commit.checks, !checks.isEmpty {
46 Section("Checks") {
47 ForEach(checks) { check in
48 HStack {
49 Image(systemName: check.state == "success"
50 ? "checkmark.circle.fill"
51 : check.state == "pending" ? "circle.dotted" : "xmark.circle.fill")
52 .foregroundStyle(check.state == "success"
53 ? Color.gbOK
54 : check.state == "pending" ? Color.gbWarn : Color.gbBad)
55 Text(check.context)
56 .font(.gbSans(.subheadline))
57 Spacer()
58 Text(check.state)
59 .font(.gbSans(.caption))
60 .foregroundStyle(.secondary)
61 }
62 }
63 }
64 }
65
66 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 {
124 ForEach(diff.files) { file in
125 DiffFileSection(file: file)
126 }
127 }
128 .listStyle(.plain)
129 }
130 }
131 .overlay { LoadStateOverlay(state: model.state) }
132 .navigationTitle("Diff")
133 .navigationBarTitleDisplayMode(.inline)
134 .task { await model.load() }
135 }
136}
gitbay/Views/Repos/LogView.swift +3 −1
@@ -11,7 +11,9 @@ struct LogView: View {
1111 var body: some View {
1212 List {
1313 ForEach(model.state.value ?? []) { commit in
14 CommitRow(commit: commit)
14 NavigationLink(value: RepoRoute.commit(repo: model.repoPath, sha: commit.sha)) {
15 CommitRow(commit: commit)
16 }
1517 }
1618 }
1719 .overlay { LoadStateOverlay(state: model.state) }
gitbay/Views/Repos/RepoListView.swift +22 −7
@@ -53,7 +53,7 @@ struct RepoListView: View {
5353
5454 /// `repo create <owner/name> [--private]` the name carries the owner,
5555 /// so org repos are created by typing org/name.
56private struct RepoCreateSheet: View {
56struct RepoCreateSheet: View {
5757
5858 let model: RepoCreateViewModel
5959 let ownerPrefix: String
@@ -164,20 +164,35 @@ struct AccountMenu: ToolbarContent {
164164 Menu {
165165 if let current = session.current {
166166 Section(current.label) {
167 Button("Sign Out", role: .destructive) {
168 session.remove(current)
167 NavigationLink(value: RepoRoute.profile(current.username)) {
168 Label("My Profile", systemImage: "person.crop.circle")
169 }
170 NavigationLink(value: RepoRoute.account) {
171 Label("Keys & Email", systemImage: "key")
169172 }
170173 }
171174 }
172 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
173 Button(account.label) {
174 session.activate(account)
175 if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty {
176 Section("Switch account") {
177 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
178 Button(account.label) {
179 session.activate(account)
180 }
181 }
182 }
183 }
184 Section {
185 NavigationLink("Add Account", value: RepoRoute.addAccount)
186 if let current = session.current {
187 Button("Sign Out", role: .destructive) {
188 session.remove(current)
189 }
175190 }
176191 }
177 NavigationLink("Add Account", value: RepoRoute.addAccount)
178192 } label: {
179193 Image(systemName: "person.crop.circle")
180194 }
195 .accessibilityIdentifier("account-menu")
181196 }
182197 }
183198 }
gitbay/Views/Repos/RepoRoute.swift +2
@@ -10,6 +10,8 @@ nonisolated enum RepoRoute: Hashable {
1010 case settings(repo: String)
1111 case grep(repo: String)
1212 case blame(repo: String, path: String, ref: String?)
13 case commit(repo: String, sha: String)
14 case commitDiff(repo: String, sha: String)
1315 case profile(String)
1416 case account
1517 case addAccount
gitbayTests/DiscoveryTests.swift −41
@@ -111,47 +111,6 @@ struct GrepViewModelTests {
111111 }
112112 }
113113
114@MainActor
115struct ProfileViewModelTests {
116
117 @Test func loadsProfileAndPrefixFilteredRepos() async throws {
118 let (client, stub) = try makeClient()
119 stub.enqueue(.init(status: 200, json: """
120 {"protocol_version":1,"data":{"name":"krz","kind":"org",\
121 "description":"warez for the public","website":"https://krz.sh"},"exit_code":0}
122 """, match: "argv=profile"))
123 stub.enqueue(.init(status: 200, json: """
124 {"protocol_version":1,"data":[\
125 {"path":"krz/gitbay","visibility":"public","topics":["git"]},\
126 {"path":"cmc/krz-notes","visibility":"public"}\
127 ],"exit_code":0}
128 """, match: "argv=search"))
129 let model = ProfileViewModel(client: client, name: "krz")
130
131 await model.load()
132
133 let profile = try #require(model.state.value)
134 #expect(profile.kind == "org")
135 // Only true owner-prefix matches; "cmc/krz-notes" merely mentions it.
136 #expect(model.repos.map(\.path) == ["krz/gitbay"])
137 }
138
139 @Test func anUnknownNameIsAnEmptyState() async throws {
140 let (client, stub) = try makeClient()
141 stub.enqueue(.init(status: 404, json:
142 #"{"protocol_version":1,"error":"no such user or org \"nobody\"","exit_code":3}"#,
143 match: "argv=profile"))
144 let model = ProfileViewModel(client: client, name: "nobody")
145
146 await model.load()
147
148 guard case .empty = model.state else {
149 Issue.record("expected .empty, got \(model.state)")
150 return
151 }
152 }
153}
154
155114 @MainActor
156115 struct RepoSearchTests {
157116
gitbayTests/ProfileCommitTests.swift added +130
@@ -0,0 +1,130 @@
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 userProfileJSON = """
16 {"protocol_version":1,"data":{"name":"cmc","kind":"user",\
17 "description":"Org-Mode · Self-Hosting · Privacy","website":"https://cleberg.net",\
18 "orgs":[{"name":"krz","role":"admin"},{"name":"audit-labs","role":"admin"}],\
19 "repos":[{"path":"cmc/notes","visibility":"private","description":"notes"},\
20 {"path":"cmc/cv","visibility":"public"}],\
21 "activity":[{"date":"2026-08-01","count":3},{"date":"2026-08-02","count":12}],\
22 "activity_total":15},"exit_code":0}
23 """
24
25private let orgProfileJSON = """
26 {"protocol_version":1,"data":{"name":"krz","kind":"org","description":"warez",\
27 "members":[{"name":"cmc","role":"admin"}],"repos":[],"activity_total":0},"exit_code":0}
28 """
29
30@MainActor
31struct ProfileAggregateTests {
32
33 @Test func aUserProfileCarriesOrgsReposAndActivity() async throws {
34 let (client, stub) = try makeClient()
35 stub.enqueue(.init(status: 200, json: userProfileJSON))
36 let model = ProfileViewModel(client: client, name: "cmc")
37
38 await model.load()
39
40 let profile = try #require(model.state.value)
41 #expect(!profile.isOrg)
42 #expect(profile.orgs?.map(\.name) == ["krz", "audit-labs"])
43 #expect(profile.repos.map(\.path) == ["cmc/notes", "cmc/cv"])
44 #expect(profile.repos[0].isPrivate)
45 #expect(profile.activityTotal == 15)
46 // One read builds the whole page.
47 #expect(stub.seen.count == 1)
48 #expect(stub.seen[0].url.query() == "argv=profile&argv=show&argv=cmc")
49 }
50
51 @Test func anOrgProfileCarriesMembersInsteadOfOrgs() async throws {
52 let (client, stub) = try makeClient()
53 stub.enqueue(.init(status: 200, json: orgProfileJSON))
54 let model = ProfileViewModel(client: client, name: "krz")
55
56 await model.load()
57
58 let profile = try #require(model.state.value)
59 #expect(profile.isOrg)
60 #expect(profile.members?.map(\.name) == ["cmc"])
61 #expect(profile.orgs == nil)
62 #expect(profile.repos.isEmpty)
63 }
64
65 @Test func anUnknownNameIsAnEmptyState() async throws {
66 let (client, stub) = try makeClient()
67 stub.enqueue(.init(status: 404, json:
68 #"{"protocol_version":1,"error":"no user or organization \"nobody\"","exit_code":3}"#))
69 let model = ProfileViewModel(client: client, name: "nobody")
70
71 await model.load()
72
73 guard case .empty = model.state else {
74 Issue.record("expected .empty, got \(model.state)")
75 return
76 }
77 }
78}
79
80@MainActor
81struct CommitDetailTests {
82
83 private let commitJSON = """
84 {"protocol_version":1,"data":{"path":"krz/gitbay",\
85 "sha":"59ae14400000000000000000000000000000abcd",\
86 "subject":"control: profile aggregate","message":"Body text.",\
87 "author_name":"Christian Cleberg","author_email":"hello@cleberg.net",\
88 "date":"2026-08-28T02:00:00Z",\
89 "signature":{"state":"verified","signer":"cmc"},\
90 "checks":[{"context":"ci","state":"success"}],\
91 "diff":"diff --git a/main.go b/main.go\\n--- a/main.go\\n+++ b/main.go\\n@@ -1,2 +1,3 @@\\n package main\\n+// added\\n"},\
92 "exit_code":0}
93 """
94
95 @Test func loadsTheCommitAndParsesItsPatch() async throws {
96 let (client, stub) = try makeClient()
97 stub.enqueue(.init(status: 200, json: commitJSON))
98 let model = CommitDetailViewModel(
99 client: client, repoPath: "krz/gitbay", sha: "59ae144")
100
101 await model.load()
102
103 let commit = try #require(model.state.value)
104 #expect(commit.subject == "control: profile aggregate")
105 #expect(commit.signature.state == .verified)
106 #expect(commit.checks?.first?.state == "success")
107 #expect(commit.shortSHA == "59ae144000")
108 // The patch is parsed by the same parser mr diff uses.
109 let diff = try #require(model.diff)
110 #expect(diff.files.map(\.displayPath) == ["main.go"])
111 #expect(diff.additions == 1)
112 #expect(stub.seen.first?.url.query() ==
113 "argv=repo&argv=commit&argv=krz/gitbay&argv=59ae144")
114 }
115
116 @Test func anUnknownShaIsAnEmptyState() async throws {
117 let (client, stub) = try makeClient()
118 stub.enqueue(.init(status: 404, json:
119 #"{"protocol_version":1,"error":"no commit \"deadbeef\" in krz/gitbay","exit_code":3}"#))
120 let model = CommitDetailViewModel(
121 client: client, repoPath: "krz/gitbay", sha: "deadbeef")
122
123 await model.load()
124
125 guard case .empty = model.state else {
126 Issue.record("expected .empty, got \(model.state)")
127 return
128 }
129 }
130}
gitbayUITests/LiveSmokeUITests.swift +79 −8
@@ -142,6 +142,20 @@ final class LiveSmokeUITests: XCTestCase {
142142 app.navigationBars.buttons.firstMatch.tap()
143143 }
144144
145 /// Keys, PGP and email live behind the profile menu now, not their
146 /// own toolbar button.
147 func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) {
148 let menu = app.descendants(matching: .any)
149 .matching(identifier: "account-menu").firstMatch
150 XCTAssertTrue(menu.waitForExistence(timeout: 15),
151 "account menu missing", file: file, line: line)
152 menu.tap()
153 let keys = app.buttons["Keys & Email"].firstMatch
154 XCTAssertTrue(keys.waitForExistence(timeout: 5),
155 "Keys & Email missing from the account menu", file: file, line: line)
156 keys.tap()
157 }
158
145159 /// Switch tabs and wait until that tab is actually front. A tap
146160 /// dispatched before the app is interactive which happens right
147161 /// after launch when there is no sign-in to slow things down is
@@ -416,10 +430,7 @@ extension LiveSmokeUITests {
416430 /// added or removed, no mail is sent.
417431 func testAccountFlows() throws {
418432 // Dashboard toolbar -> account screen.
419 let accountLink = app.descendants(matching: .any)
420 .matching(identifier: "account-screen-link").firstMatch
421 XCTAssertTrue(accountLink.waitForExistence(timeout: 10))
422 accountLink.tap()
433 openAccountScreen()
423434
424435 // Real keys render: SSH fingerprints and the PGP key's UID email.
425436 XCTAssertTrue(app.staticTexts
@@ -464,10 +475,7 @@ extension LiveSmokeUITests {
464475 /// granted a repo, the grant revoked, the team deleted. Everything
465476 /// this test makes, it removes.
466477 func testOrgFlows() throws {
467 let accountLink = app.descendants(matching: .any)
468 .matching(identifier: "account-screen-link").firstMatch
469 XCTAssertTrue(accountLink.waitForExistence(timeout: 10))
470 accountLink.tap()
478 openAccountScreen()
471479
472480 let orgRow = app.staticTexts["krz"].firstMatch
473481 XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
@@ -629,3 +637,66 @@ extension LiveSmokeUITests {
629637 .waitForExistence(timeout: 20), "edit not reflected after commit")
630638 }
631639 }
640
641
642extension LiveSmokeUITests {
643
644 /// The navigation and profile changes: identity lives in one menu,
645 /// the dashboard can create a repository, a profile is a real
646 /// profile, and a log entry opens its commit.
647 func testProfileAndNavigationFlows() throws {
648 // --- the dashboard offers creation ---
649 selectTab("Dashboard")
650 let create = app.descendants(matching: .any)
651 .matching(identifier: "dashboard-create-button").firstMatch
652 XCTAssertTrue(create.waitForExistence(timeout: 15), "dashboard + missing")
653 create.tap()
654 let pathField = app.descendants(matching: .any)
655 .matching(identifier: "repo-create-path").firstMatch
656 XCTAssertTrue(pathField.waitForExistence(timeout: 10),
657 "dashboard + did not open the create sheet")
658 app.buttons["Cancel"].firstMatch.tap()
659
660 // --- identity is one menu: profile and keys together ---
661 let menu = app.descendants(matching: .any)
662 .matching(identifier: "account-menu").firstMatch
663 XCTAssertTrue(menu.waitForExistence(timeout: 10))
664 menu.tap()
665 XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
666 "keys not in the account menu")
667 let myProfile = app.buttons["My Profile"].firstMatch
668 XCTAssertTrue(myProfile.exists, "profile not in the account menu")
669 myProfile.tap()
670
671 // --- a profile is a profile: description, links, orgs, graph, repos ---
672 XCTAssertTrue(app.staticTexts
673 .containing(NSPredicate(format: "label CONTAINS 'Self-Hosting'")).firstMatch
674 .waitForExistence(timeout: 20), "profile description missing")
675 XCTAssertTrue(app.staticTexts["Organizations"].firstMatch.exists,
676 "org memberships missing")
677 XCTAssertTrue(app.staticTexts["krz"].firstMatch.exists, "org row missing")
678 XCTAssertTrue(app.staticTexts
679 .containing(NSPredicate(format: "label CONTAINS 'contributions in the last year'")).firstMatch
680 .exists, "activity graph missing")
681 XCTAssertTrue(app.staticTexts
682 .containing(NSPredicate(format: "label BEGINSWITH 'Repositories'")).firstMatch
683 .exists, "repositories missing")
684
685 // --- a log entry opens its commit ---
686 app.terminate()
687 app.launch()
688 openRepo("krz/gitbay")
689 app.staticTexts["History"].firstMatch.tap()
690 let firstCommit = app.cells.firstMatch
691 XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
692 firstCommit.tap()
693 // The commit screen shows its changed files and can open the patch.
694 XCTAssertTrue(app.descendants(matching: .any)
695 .matching(identifier: "commit-diff-button").firstMatch
696 .waitForExistence(timeout: 20), "commit did not open")
697 app.descendants(matching: .any).matching(identifier: "commit-diff-button")
698 .firstMatch.tap()
699 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
700 "commit diff rendered nothing")
701 }
702}