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

merged merged by cmc on 2026-08-28 03:14 UTC · krz/gitbay-ios:profile-and-navigation into main

32 files changed, +802 −231

Layout: unified · split

gitbay/ContentView.swift +4
@@ -80,6 +80,10 @@ private struct RouteDestinations: ViewModifier {
80 GrepView(client: client, repo: repo) 80 GrepView(client: client, repo: repo)
81 case .blame(let repo, let path, let ref): 81 case .blame(let repo, let path, let ref):
82 BlameView(client: client, repo: repo, path: path, ref: ref) 82 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)
83 case .profile(let name): 87 case .profile(let name):
84 ProfileView(client: client, name: name) 88 ProfileView(client: client, name: name)
85 case .account: 89 case .account:
gitbay/Discovery/ProfileViewModel.swift +40 −8
@@ -1,8 +1,9 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// `profile show <name>` plus the profile's reachable repositories via 4/// `profile show <name>` — the whole profile in one read: description,
5/// `repo search` filtered to the owner prefix. 5/// links, org membership (or an org's members), the repositories the
6/// account may see, and a year of activity.
6@Observable 7@Observable
7@MainActor 8@MainActor
8final class ProfileViewModel { 9final class ProfileViewModel {
@@ -12,10 +13,46 @@ final class ProfileViewModel {
12 let kind: String // user | org 13 let kind: String // user | org
13 let description: String? 14 let description: String?
14 let website: String? 15 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 }
15 } 53 }
16 54
17 private(set) var state: LoadState<Profile> = .loading 55 private(set) var state: LoadState<Profile> = .loading
18 private(set) var repos: [RepoSummary] = []
19 56
20 private let client: GitbayClient 57 private let client: GitbayClient
21 let name: String 58 let name: String
@@ -30,11 +67,6 @@ final class ProfileViewModel {
30 state = .loaded(try await client.read(["profile", "show", name], as: Profile.self)) 67 state = .loaded(try await client.read(["profile", "show", name], as: Profile.self))
31 } catch { 68 } catch {
32 state = .from(error) 69 state = .from(error)
33 return
34 } 70 }
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 }
39 } 71 }
40} 72}
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/Theme/Theme.swift +33
@@ -32,6 +32,13 @@ extension Color {
32 nonisolated static let gbCodeBackground = paired(0xF5F5F5, 0x050505) 32 nonisolated static let gbCodeBackground = paired(0xF5F5F5, 0x050505)
33 /// --fill-subtle: hunk headers and quiet fills. 33 /// --fill-subtle: hunk headers and quiet fills.
34 nonisolated static let gbFillSubtle = paired(0xECECEC, 0x161616) 34 nonisolated static let gbFillSubtle = paired(0xECECEC, 0x161616)
35 /// --faint: hairlines, row separators, and an empty calendar cell.
36 nonisolated static let gbFaint = paired(0xEAEAEA, 0x1C1C1C)
37 /// --bg: the page ground the accent is mixed toward. Chrome uses the
38 /// system background; this is for blends that must match the web.
39 nonisolated static let gbBackground = paired(0xFFFFFF, 0x0A0A0A)
40 /// --line: control borders and table edges.
41 nonisolated static let gbLine = paired(0xD4D4D4, 0x2E2E2E)
35} 42}
36 43
37extension UIColor { 44extension UIColor {
@@ -96,6 +103,32 @@ enum GitbayFonts {
96 } 103 }
97} 104}
98 105
106/// The web's notice idiom: a 3px stripe in the state's color down the
107/// left edge, not a tinted fill. `.error` uses --bad, `.notice` --ok;
108/// anything the server refused is --warn.
109struct GBNotice: View {
110 let text: String
111 let color: Color
112
113 init(_ text: String, _ color: Color = .gbBad) {
114 self.text = text
115 self.color = color
116 }
117
118 var body: some View {
119 HStack(alignment: .top, spacing: 10) {
120 Rectangle()
121 .fill(color)
122 .frame(width: 3)
123 Text(text)
124 .font(.gbSans(.subheadline))
125 .frame(maxWidth: .infinity, alignment: .leading)
126 }
127 .fixedSize(horizontal: false, vertical: true)
128 .padding(.vertical, 2)
129 }
130}
131
99/// The web's `.chip` recipe: state color, a 7% tint ground, a 35% tint 132/// The web's `.chip` recipe: state color, a 7% tint ground, a 35% tint
100/// border, 2px radius. 133/// border, 2px radius.
101struct GBChip: View { 134struct GBChip: View {
gitbay/Views/Account/AccountView.swift +5 −18
@@ -27,11 +27,7 @@ struct AccountView: View {
27 Label(membership.org, systemImage: "building.2") 27 Label(membership.org, systemImage: "building.2")
28 .font(.gbSans(.subheadline)) 28 .font(.gbSans(.subheadline))
29 Spacer() 29 Spacer()
30 Text(membership.role) 30 GBChip(membership.role, .secondary)
31 .font(.gbSans(.caption))
32 .padding(.horizontal, 6)
33 .padding(.vertical, 2)
34 .background(.quaternary, in: gbChipShape)
35 } 31 }
36 } 32 }
37 } 33 }
@@ -44,16 +40,12 @@ struct AccountView: View {
44 // scrolled out of existence. 40 // scrolled out of existence.
45 if let error = model.actionError { 41 if let error = model.actionError {
46 Section { 42 Section {
47 Label(error, systemImage: "hand.raised") 43 GBNotice(error, .gbWarn)
48 .foregroundStyle(Color.gbWarn)
49 .font(.gbSans(.subheadline))
50 } 44 }
51 } 45 }
52 if let notice = model.notice { 46 if let notice = model.notice {
53 Section { 47 Section {
54 Label(notice, systemImage: "envelope") 48 GBNotice(notice, .gbOK)
55 .foregroundStyle(.secondary)
56 .font(.gbSans(.subheadline))
57 } 49 }
58 } 50 }
59 emailSection 51 emailSection
@@ -136,10 +128,7 @@ struct AccountView: View {
136 .truncationMode(.middle) 128 .truncationMode(.middle)
137 HStack(spacing: 6) { 129 HStack(spacing: 6) {
138 Text(key.algo) 130 Text(key.algo)
139 Text(key.scope) 131 GBChip(key.scope, .secondary)
140 .padding(.horizontal, 5)
141 .padding(.vertical, 1)
142 .background(.quaternary, in: gbChipShape)
143 } 132 }
144 .font(.gbSans(.caption2)) 133 .font(.gbSans(.caption2))
145 .foregroundStyle(.secondary) 134 .foregroundStyle(.secondary)
@@ -273,9 +262,7 @@ private struct KeyPasteSheet: View {
273 } 262 }
274 if let errorMessage { 263 if let errorMessage {
275 Section { 264 Section {
276 Label(errorMessage, systemImage: "exclamationmark.triangle") 265 GBNotice(errorMessage)
277 .foregroundStyle(Color.gbBad)
278 .font(.gbSans(.subheadline))
279 } 266 }
280 } 267 }
281 } 268 }
gitbay/Views/Builds/BuildListView.swift +1 −3
@@ -14,9 +14,7 @@ struct BuildListView: View {
14 List { 14 List {
15 if let error = model.actionError { 15 if let error = model.actionError {
16 Section { 16 Section {
17 Label(error, systemImage: "hand.raised") 17 GBNotice(error, .gbWarn)
18 .foregroundStyle(Color.gbWarn)
19 .font(.gbSans(.subheadline))
20 } 18 }
21 } 19 }
22 ForEach(model.state.value ?? []) { build in 20 ForEach(model.state.value ?? []) { build in
gitbay/Views/Dashboard/DashboardView.swift +18 −3
@@ -4,10 +4,14 @@ import SwiftUI
4/// like the logged-in web dashboard. 4/// like the logged-in web dashboard.
5struct DashboardView: View { 5struct DashboardView: View {
6 6
7 @Environment(SessionStore.self) private var session
7 @State private var model: DashboardViewModel 8 @State private var model: DashboardViewModel
9 @State private var createModel: RepoCreateViewModel
10 @State private var composing = false
8 11
9 init(client: GitbayClient) { 12 init(client: GitbayClient) {
10 _model = State(initialValue: DashboardViewModel(client: client)) 13 _model = State(initialValue: DashboardViewModel(client: client))
14 _createModel = State(initialValue: RepoCreateViewModel(client: client))
11 } 15 }
12 16
13 var body: some View { 17 var body: some View {
@@ -41,13 +45,24 @@ struct DashboardView: View {
41 .navigationTitle("Dashboard") 45 .navigationTitle("Dashboard")
42 .toolbar { 46 .toolbar {
43 ToolbarItem(placement: .topBarTrailing) { 47 ToolbarItem(placement: .topBarTrailing) {
44 NavigationLink(value: RepoRoute.account) { 48 Button {
45 Image(systemName: "key") 49 composing = true
50 } label: {
51 Image(systemName: "plus")
46 } 52 }
47 .accessibilityIdentifier("account-screen-link") 53 .accessibilityIdentifier("dashboard-create-button")
48 } 54 }
49 AccountMenu() 55 AccountMenu()
50 } 56 }
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 }
51 .task { await model.load() } 66 .task { await model.load() }
52 .refreshable { await model.load() } 67 .refreshable { await model.load() }
53 } 68 }
gitbay/Views/Discovery/ActivityGraph.swift added +101
@@ -0,0 +1,101 @@
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 /// The same five buckets activityLevel uses server-side.
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: 11, height: 11)
79 }
80 }
81 }
82 }
83 .padding(.vertical, 4)
84 }
85 .defaultScrollAnchor(.trailing)
86 }
87
88 /// The stylesheet's ramp: an empty day is --faint, and the rest mix
89 /// --accent toward --bg at 25/50/75/100 percent. A day outside the
90 /// window draws nothing at all.
91 private func color(for cell: Cell?) -> Color {
92 guard let cell else { return .clear }
93 return switch cell.level {
94 case 0: Color.gbFaint
95 case 1: Color.gbAccent.mix(with: .gbBackground, by: 0.75)
96 case 2: Color.gbAccent.mix(with: .gbBackground, by: 0.50)
97 case 3: Color.gbAccent.mix(with: .gbBackground, by: 0.25)
98 default: Color.gbAccent
99 }
100 }
101}
gitbay/Views/Discovery/ProfileView.swift +108 −48
@@ -1,6 +1,7 @@
1import SwiftUI 1import SwiftUI
2 2
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.
4struct ProfileView: View { 5struct ProfileView: View {
5 6
6 @State private var model: ProfileViewModel 7 @State private var model: ProfileViewModel
@@ -12,67 +13,126 @@ struct ProfileView: View {
12 var body: some View { 13 var body: some View {
13 List { 14 List {
14 if let profile = model.state.value { 15 if let profile = model.state.value {
15 Section { 16 header(profile)
16 VStack(alignment: .leading, spacing: 6) { 17 peopleSection(profile)
17 HStack(spacing: 8) { 18 activitySection(profile)
18 Image(systemName: profile.kind == "org" 19 reposSection(profile)
19 ? "building.2" : "person.crop.circle") 20 if profile.isOrg {
20 .font(.gbSans(.title2)) 21 Section {
21 .foregroundStyle(.secondary) 22 NavigationLink(value: OrgRoute.org(profile.name)) {
22 Text(profile.name) 23 Label("Members & Teams", systemImage: "person.3")
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)
39 } 24 }
40 } 25 }
41 .padding(.vertical, 4)
42 } 26 }
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 }
43 35
44 if profile.kind == "org" { 36 // MARK: - Sections
45 Section { 37
46 NavigationLink(value: OrgRoute.org(profile.name)) { 38 private func header(_ profile: ProfileViewModel.Profile) -> some View {
47 Label("Members & Teams", systemImage: "person.3") 39 Section {
40 VStack(alignment: .leading, spacing: 8) {
41 HStack(spacing: 8) {
42 Image(systemName: profile.isOrg ? "building.2" : "person.crop.circle")
43 .font(.gbSans(.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 }
48 } 84 }
49 } 85 }
50 } 86 }
87 }
88 }
89 }
51 90
52 if !model.repos.isEmpty { 91 @ViewBuilder
53 Section("Repositories") { 92 private func activitySection(_ profile: ProfileViewModel.Profile) -> some View {
54 ForEach(model.repos) { repo in 93 if let activity = profile.activity, !activity.isEmpty {
55 NavigationLink(value: RepoRoute.repo(repo.path)) { 94 Section {
56 VStack(alignment: .leading, spacing: 2) { 95 ActivityGraph(days: activity)
57 Text(repo.name) 96 } header: {
58 .font(.gbSans(.subheadline).weight(.medium)) 97 Text("Activity")
59 if let description = repo.description, !description.isEmpty { 98 } footer: {
60 Text(description) 99 Text("\(profile.activityTotal) contributions in the last year.")
61 .font(.gbSans(.caption)) 100 }
62 .foregroundStyle(.secondary) 101 }
63 .lineLimit(1) 102 }
64 } 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)
65 } 121 }
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)
66 } 131 }
67 } 132 }
68 } 133 }
69 } 134 }
70 } 135 }
71 } 136 }
72 .overlay { LoadStateOverlay(state: model.state) }
73 .navigationTitle(model.name)
74 .navigationBarTitleDisplayMode(.inline)
75 .task { await model.load() }
76 .refreshable { await model.load() }
77 } 137 }
78} 138}
gitbay/Views/Issues/IssueListView.swift +1 −5
@@ -86,11 +86,7 @@ private struct IssueRow: View {
86 .font(.gbSans(.caption2)) 86 .font(.gbSans(.caption2))
87 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad) 87 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
88 ForEach(issue.labels ?? [], id: \.self) { label in 88 ForEach(issue.labels ?? [], id: \.self) { label in
89 Text(label) 89 GBChip(label, .secondary)
90 .font(.gbSans(.caption2))
91 .padding(.horizontal, 5)
92 .padding(.vertical, 1)
93 .background(.quaternary, in: gbChipShape)
94 } 90 }
95 Spacer() 91 Spacer()
96 if let assignees = issue.assignees, !assignees.isEmpty { 92 if let assignees = issue.assignees, !assignees.isEmpty {
gitbay/Views/Issues/IssueView.swift +3 −4
@@ -23,9 +23,7 @@ struct IssueView: View {
23 23
24 if let error = model.actionError { 24 if let error = model.actionError {
25 Section { 25 Section {
26 Label(error, systemImage: "hand.raised") 26 GBNotice(error, .gbWarn)
27 .foregroundStyle(Color.gbWarn)
28 .font(.gbSans(.subheadline))
29 } 27 }
30 } 28 }
31 29
@@ -170,7 +168,8 @@ struct IssueView: View {
170 .font(.gbSans(.caption)) 168 .font(.gbSans(.caption))
171 .padding(.horizontal, 8) 169 .padding(.horizontal, 8)
172 .padding(.vertical, 3) 170 .padding(.vertical, 3)
173 .background(.quaternary, in: gbChipShape) 171 .background(Color.secondary.opacity(0.07), in: gbChipShape)
172 .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1))
174 } 173 }
175 } 174 }
176 } 175 }
gitbay/Views/MRs/DiffView.swift +22 −11
@@ -18,7 +18,7 @@ struct DiffView: View {
18 if let diff = model.diff, !diff.files.isEmpty { 18 if let diff = model.diff, !diff.files.isEmpty {
19 List { 19 List {
20 ForEach(diff.files) { file in 20 ForEach(diff.files) { file in
21 FileDiffSection(file: file, model: model) 21 DiffFileSection(file: file, model: model)
22 } 22 }
23 let detached = model.threads.filter { thread in 23 let detached = model.threads.filter { thread in
24 thread.stale || !diff.anchors(thread) 24 thread.stale || !diff.anchors(thread)
@@ -46,10 +46,11 @@ struct DiffView: View {
46 } 46 }
47} 47}
48 48
49private struct FileDiffSection: View { 49struct DiffFileSection: View {
50 50
51 let file: UnifiedDiff.File 51 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
53 @State private var collapsed = false 54 @State private var collapsed = false
54 55
55 var body: some View { 56 var body: some View {
@@ -97,24 +98,34 @@ private struct HunkView: View {
97 98
98 let file: UnifiedDiff.File 99 let file: UnifiedDiff.File
99 let hunk: UnifiedDiff.Hunk 100 let hunk: UnifiedDiff.Hunk
100 let model: MRDetailViewModel 101 let model: MRDetailViewModel?
101 102
102 var body: some View { 103 var body: some View {
103 ScrollView(.horizontal) { 104 ScrollView(.horizontal) {
104 VStack(alignment: .leading, spacing: 0) { 105 VStack(alignment: .leading, spacing: 0) {
106 // The stylesheet colors the hunk header with --accent on
107 // --fill-subtle, ruled above and below with --line.
105 Text(hunk.header) 108 Text(hunk.header)
106 .font(.gbMono(.caption2)) 109 .font(.gbMono(.caption2))
107 .foregroundStyle(.secondary) 110 .foregroundStyle(Color.gbAccent)
108 .padding(.vertical, 2) 111 .padding(.vertical, 3)
109 .frame(maxWidth: .infinity, alignment: .leading) 112 .frame(maxWidth: .infinity, alignment: .leading)
110 .background(Color.gbFillSubtle) 113 .background(Color.gbFillSubtle)
114 .overlay(alignment: .top) {
115 Rectangle().fill(Color.gbLine).frame(height: 1)
116 }
117 .overlay(alignment: .bottom) {
118 Rectangle().fill(Color.gbLine).frame(height: 1)
119 }
111 ForEach(hunk.lines) { line in 120 ForEach(hunk.lines) { line in
112 LineView(line: line) 121 LineView(line: line)
113 ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in 122 if let model {
114 ReviewThreadView(thread: thread, model: model) 123 ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in
115 .padding(.vertical, 6) 124 ReviewThreadView(thread: thread, model: model)
116 .padding(.horizontal, 8) 125 .padding(.vertical, 6)
117 .background(Color.gbFillSubtle) 126 .padding(.horizontal, 8)
127 .background(Color.gbFillSubtle)
128 }
118 } 129 }
119 } 130 }
120 } 131 }
gitbay/Views/MRs/MRListView.swift +1 −3
@@ -93,9 +93,7 @@ private struct MRCreateSheet: View {
93 } 93 }
94 if let error = model.errorMessage { 94 if let error = model.errorMessage {
95 Section { 95 Section {
96 Label(error, systemImage: "exclamationmark.triangle") 96 GBNotice(error)
97 .foregroundStyle(Color.gbBad)
98 .font(.gbSans(.subheadline))
99 } 97 }
100 } 98 }
101 } 99 }
gitbay/Views/MRs/MRView.swift +3 −13
@@ -23,9 +23,7 @@ struct MRView: View {
23 23
24 if let error = model.actionError { 24 if let error = model.actionError {
25 Section { 25 Section {
26 Label(error, systemImage: "hand.raised") 26 GBNotice(error, .gbWarn)
27 .foregroundStyle(Color.gbWarn)
28 .font(.gbSans(.subheadline))
29 } 27 }
30 } 28 }
31 29
@@ -180,11 +178,7 @@ struct MRView: View {
180 .font(.gbSans(.subheadline)) 178 .font(.gbSans(.subheadline))
181 Spacer() 179 Spacer()
182 if review.stale { 180 if review.stale {
183 Text("stale") 181 GBChip("stale", .gbWarn)
184 .font(.gbSans(.caption2))
185 .padding(.horizontal, 5)
186 .padding(.vertical, 1)
187 .background(.quaternary, in: gbChipShape)
188 } 182 }
189 } 183 }
190 } 184 }
@@ -316,11 +310,7 @@ struct ReviewThreadView: View {
316 .font(.gbMono(.caption)) 310 .font(.gbMono(.caption))
317 .lineLimit(1) 311 .lineLimit(1)
318 if thread.stale { 312 if thread.stale {
319 Text("stale") 313 GBChip("stale", .gbWarn)
320 .font(.gbSans(.caption2))
321 .padding(.horizontal, 5)
322 .padding(.vertical, 1)
323 .background(.quaternary, in: gbChipShape)
324 } 314 }
325 Spacer() 315 Spacer()
326 Button(thread.isResolved ? "Reopen" : "Resolve") { 316 Button(thread.isResolved ? "Reopen" : "Resolve") {
gitbay/Views/Orgs/OrgView.swift +2 −8
@@ -20,9 +20,7 @@ struct OrgView: View {
20 if let loaded = model.state.value { 20 if let loaded = model.state.value {
21 if let error = model.actionError { 21 if let error = model.actionError {
22 Section { 22 Section {
23 Label(error, systemImage: "hand.raised") 23 GBNotice(error, .gbWarn)
24 .foregroundStyle(Color.gbWarn)
25 .font(.gbSans(.subheadline))
26 } 24 }
27 } 25 }
28 membersSection(loaded.members) 26 membersSection(loaded.members)
@@ -79,11 +77,7 @@ struct OrgView: View {
79 Button("Admin") { Task { await model.setRole(member, role: "admin") } } 77 Button("Admin") { Task { await model.setRole(member, role: "admin") } }
80 Button("Member") { Task { await model.setRole(member, role: "member") } } 78 Button("Member") { Task { await model.setRole(member, role: "member") } }
81 } label: { 79 } label: {
82 Text(member.role) 80 GBChip(member.role, .secondary)
83 .font(.gbSans(.caption))
84 .padding(.horizontal, 6)
85 .padding(.vertical, 2)
86 .background(.quaternary, in: gbChipShape)
87 } 81 }
88 .disabled(model.working) 82 .disabled(model.working)
89 } 83 }
gitbay/Views/Orgs/TeamView.swift +2 −8
@@ -17,9 +17,7 @@ struct TeamView: View {
17 if let loaded = model.state.value { 17 if let loaded = model.state.value {
18 if let error = model.actionError { 18 if let error = model.actionError {
19 Section { 19 Section {
20 Label(error, systemImage: "hand.raised") 20 GBNotice(error, .gbWarn)
21 .foregroundStyle(Color.gbWarn)
22 .font(.gbSans(.subheadline))
23 } 21 }
24 } 22 }
25 23
@@ -56,11 +54,7 @@ struct TeamView: View {
56 .font(.gbSans(.subheadline)) 54 .font(.gbSans(.subheadline))
57 .lineLimit(1) 55 .lineLimit(1)
58 Spacer() 56 Spacer()
59 Text(grant.role) 57 GBChip(grant.role, .secondary)
60 .font(.gbSans(.caption))
61 .padding(.horizontal, 6)
62 .padding(.vertical, 2)
63 .background(.quaternary, in: gbChipShape)
64 } 58 }
65 .swipeActions { 59 .swipeActions {
66 Button("Revoke", role: .destructive) { 60 Button("Revoke", role: .destructive) {
gitbay/Views/Releases/ReleaseListView.swift +1 −3
@@ -100,9 +100,7 @@ private struct ReleaseCreateSheet: View {
100 } 100 }
101 if let error = model.createError { 101 if let error = model.createError {
102 Section { 102 Section {
103 Label(error, systemImage: "exclamationmark.triangle") 103 GBNotice(error)
104 .foregroundStyle(Color.gbBad)
105 .font(.gbSans(.subheadline))
106 } 104 }
107 } 105 }
108 } 106 }
gitbay/Views/Releases/ReleaseView.swift +1 −3
@@ -18,9 +18,7 @@ struct ReleaseView: View {
18 if let release = model.state.value { 18 if let release = model.state.value {
19 if let error = model.actionError { 19 if let error = model.actionError {
20 Section { 20 Section {
21 Label(error, systemImage: "hand.raised") 21 GBNotice(error, .gbWarn)
22 .foregroundStyle(Color.gbWarn)
23 .font(.gbSans(.subheadline))
24 } 22 }
25 } 23 }
26 Section { 24 Section {
gitbay/Views/Repos/BlameView.swift +5 −2
@@ -76,8 +76,11 @@ private struct HunkAttribution: View {
76 .foregroundStyle(.tertiary) 76 .foregroundStyle(.tertiary)
77 } 77 }
78 .padding(.horizontal, 10) 78 .padding(.horizontal, 10)
79 .padding(.vertical, 4) 79 .padding(.vertical, 6)
80 .frame(minWidth: 360, alignment: .leading) 80 .frame(minWidth: 360, alignment: .leading)
81 .background(Color.gbFillSubtle) 81 // .blamehunk is ruled off from the one above, not filled.
82 .overlay(alignment: .top) {
83 Rectangle().fill(Color.gbLine).frame(height: 1)
84 }
82 } 85 }
83} 86}
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/FileEditSheet.swift +1 −3
@@ -35,9 +35,7 @@ struct FileEditSheet: View {
35 } 35 }
36 if let error = model.actionError { 36 if let error = model.actionError {
37 Section { 37 Section {
38 Label(error, systemImage: "hand.raised") 38 GBNotice(error, .gbWarn)
39 .foregroundStyle(Color.gbWarn)
40 .font(.gbSans(.subheadline))
41 } 39 }
42 } 40 }
43 } 41 }
gitbay/Views/Repos/FileView.swift +4 −6
@@ -69,14 +69,12 @@ struct FileView: View {
69 } else if let text = file.content { 69 } else if let text = file.content {
70 VStack(spacing: 0) { 70 VStack(spacing: 0) {
71 if file.isTruncated { 71 if file.isTruncated {
72 Label( 72 GBNotice(
73 "Truncated — showing the first \(Int64(file.size).formatted(.byteCount(style: .file))).", 73 "Truncated — showing the first \(Int64(file.size).formatted(.byteCount(style: .file))).",
74 systemImage: "scissors" 74 .gbWarn
75 ) 75 )
76 .font(.gbSans(.caption)) 76 .padding(.horizontal, 12)
77 .frame(maxWidth: .infinity, alignment: .leading) 77 .padding(.vertical, 6)
78 .padding(8)
79 .background(.yellow.opacity(0.15))
80 } 78 }
81 CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme) 79 CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme)
82 } 80 }
gitbay/Views/Repos/LogView.swift +3 −1
@@ -11,7 +11,9 @@ struct LogView: View {
11 var body: some View { 11 var body: some View {
12 List { 12 List {
13 ForEach(model.state.value ?? []) { commit in 13 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 }
15 } 17 }
16 } 18 }
17 .overlay { LoadStateOverlay(state: model.state) } 19 .overlay { LoadStateOverlay(state: model.state) }
gitbay/Views/Repos/RepoListView.swift +24 −15
@@ -53,7 +53,7 @@ struct RepoListView: View {
53 53
54/// `repo create <owner/name> [--private]` — the name carries the owner, 54/// `repo create <owner/name> [--private]` — the name carries the owner,
55/// so org repos are created by typing org/name. 55/// so org repos are created by typing org/name.
56private struct RepoCreateSheet: View { 56struct RepoCreateSheet: View {
57 57
58 let model: RepoCreateViewModel 58 let model: RepoCreateViewModel
59 let ownerPrefix: String 59 let ownerPrefix: String
@@ -80,9 +80,7 @@ private struct RepoCreateSheet: View {
80 } 80 }
81 if let error = model.errorMessage { 81 if let error = model.errorMessage {
82 Section { 82 Section {
83 Label(error, systemImage: "exclamationmark.triangle") 83 GBNotice(error)
84 .foregroundStyle(Color.gbBad)
85 .font(.gbSans(.subheadline))
86 } 84 }
87 } 85 }
88 } 86 }
@@ -136,11 +134,7 @@ private struct RepoRow: View {
136 .foregroundStyle(.secondary) 134 .foregroundStyle(.secondary)
137 } 135 }
138 if repo.isArchived { 136 if repo.isArchived {
139 Text("archived") 137 GBChip("archived", .secondary)
140 .font(.gbSans(.caption2))
141 .padding(.horizontal, 5)
142 .padding(.vertical, 1)
143 .background(.quaternary, in: gbChipShape)
144 } 138 }
145 } 139 }
146 if let description = repo.description, !description.isEmpty { 140 if let description = repo.description, !description.isEmpty {
@@ -164,20 +158,35 @@ struct AccountMenu: ToolbarContent {
164 Menu { 158 Menu {
165 if let current = session.current { 159 if let current = session.current {
166 Section(current.label) { 160 Section(current.label) {
167 Button("Sign Out", role: .destructive) { 161 NavigationLink(value: RepoRoute.profile(current.username)) {
168 session.remove(current) 162 Label("My Profile", systemImage: "person.crop.circle")
163 }
164 NavigationLink(value: RepoRoute.account) {
165 Label("Keys & Email", systemImage: "key")
166 }
167 }
168 }
169 if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty {
170 Section("Switch account") {
171 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
172 Button(account.label) {
173 session.activate(account)
174 }
169 } 175 }
170 } 176 }
171 } 177 }
172 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in 178 Section {
173 Button(account.label) { 179 NavigationLink("Add Account", value: RepoRoute.addAccount)
174 session.activate(account) 180 if let current = session.current {
181 Button("Sign Out", role: .destructive) {
182 session.remove(current)
183 }
175 } 184 }
176 } 185 }
177 NavigationLink("Add Account", value: RepoRoute.addAccount)
178 } label: { 186 } label: {
179 Image(systemName: "person.crop.circle") 187 Image(systemName: "person.crop.circle")
180 } 188 }
189 .accessibilityIdentifier("account-menu")
181 } 190 }
182 } 191 }
183} 192}
gitbay/Views/Repos/RepoRoute.swift +2
@@ -10,6 +10,8 @@ nonisolated enum RepoRoute: Hashable {
10 case settings(repo: String) 10 case settings(repo: String)
11 case grep(repo: String) 11 case grep(repo: String)
12 case blame(repo: String, path: String, ref: String?) 12 case blame(repo: String, path: String, ref: String?)
13 case commit(repo: String, sha: String)
14 case commitDiff(repo: String, sha: String)
13 case profile(String) 15 case profile(String)
14 case account 16 case account
15 case addAccount 17 case addAccount
gitbay/Views/Repos/RepoSettingsView.swift +4 −4
@@ -20,9 +20,7 @@ struct RepoSettingsView: View {
20 if let loaded = model.state.value { 20 if let loaded = model.state.value {
21 if let error = model.actionError { 21 if let error = model.actionError {
22 Section { 22 Section {
23 Label(error, systemImage: "hand.raised") 23 GBNotice(error, .gbWarn)
24 .foregroundStyle(Color.gbWarn)
25 .font(.gbSans(.subheadline))
26 } 24 }
27 } 25 }
28 aboutSection(loaded) 26 aboutSection(loaded)
@@ -98,9 +96,11 @@ struct RepoSettingsView: View {
98 .disabled(model.working) 96 .disabled(model.working)
99 } 97 }
100 .font(.gbSans(.caption)) 98 .font(.gbSans(.caption))
99 .foregroundStyle(Color.gbAccent)
101 .padding(.horizontal, 8) 100 .padding(.horizontal, 8)
102 .padding(.vertical, 3) 101 .padding(.vertical, 3)
103 .background(.quaternary, in: gbChipShape) 102 .background(Color.gbAccent.opacity(0.07), in: gbChipShape)
103 .overlay(gbChipShape.stroke(Color.gbAccent.opacity(0.35), lineWidth: 1))
104 } 104 }
105 } 105 }
106 } 106 }
gitbay/Views/Repos/RepoView.swift +2 −8
@@ -17,9 +17,7 @@ struct RepoView: View {
17 if let detail = model.state.value { 17 if let detail = model.state.value {
18 if let error = model.actionError { 18 if let error = model.actionError {
19 Section { 19 Section {
20 Label(error, systemImage: "hand.raised") 20 GBNotice(error, .gbWarn)
21 .foregroundStyle(Color.gbWarn)
22 .font(.gbSans(.subheadline))
23 } 21 }
24 } 22 }
25 header(detail) 23 header(detail)
@@ -130,11 +128,7 @@ struct RepoView: View {
130 .foregroundStyle(.secondary) 128 .foregroundStyle(.secondary)
131 } 129 }
132 if detail.isArchived { 130 if detail.isArchived {
133 Text("archived") 131 GBChip("archived", .secondary)
134 .font(.gbSans(.caption2))
135 .padding(.horizontal, 5)
136 .padding(.vertical, 1)
137 .background(.quaternary, in: gbChipShape)
138 } 132 }
139 } 133 }
140 if let description = detail.description, !description.isEmpty { 134 if let description = detail.description, !description.isEmpty {
gitbay/Views/Shared/ComposeSheet.swift +1 −3
@@ -33,9 +33,7 @@ struct ComposeSheet: View {
33 } 33 }
34 if let errorMessage { 34 if let errorMessage {
35 Section { 35 Section {
36 Label(errorMessage, systemImage: "exclamationmark.triangle") 36 GBNotice(errorMessage)
37 .foregroundStyle(Color.gbBad)
38 .font(.gbSans(.subheadline))
39 } 37 }
40 } 38 }
41 } 39 }
gitbay/Views/SignInView.swift +1 −2
@@ -52,8 +52,7 @@ struct SignInView: View {
52 52
53 if let errorMessage { 53 if let errorMessage {
54 Section { 54 Section {
55 Label(errorMessage, systemImage: "exclamationmark.triangle") 55 GBNotice(errorMessage)
56 .foregroundStyle(Color.gbBad)
57 } 56 }
58 } 57 }
59 58
gitbayTests/DiscoveryTests.swift −41
@@ -111,47 +111,6 @@ struct GrepViewModelTests {
111 } 111 }
112} 112}
113 113
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
155@MainActor 114@MainActor
156struct RepoSearchTests { 115struct RepoSearchTests {
157 116
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 {
142 app.navigationBars.buttons.firstMatch.tap() 142 app.navigationBars.buttons.firstMatch.tap()
143 } 143 }
144 144
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
145 /// Switch tabs and wait until that tab is actually front. A tap 159 /// Switch tabs and wait until that tab is actually front. A tap
146 /// dispatched before the app is interactive — which happens right 160 /// dispatched before the app is interactive — which happens right
147 /// after launch when there is no sign-in to slow things down — is 161 /// after launch when there is no sign-in to slow things down — is
@@ -416,10 +430,7 @@ extension LiveSmokeUITests {
416 /// added or removed, no mail is sent. 430 /// added or removed, no mail is sent.
417 func testAccountFlows() throws { 431 func testAccountFlows() throws {
418 // Dashboard toolbar -> account screen. 432 // Dashboard toolbar -> account screen.
419 let accountLink = app.descendants(matching: .any) 433 openAccountScreen()
420 .matching(identifier: "account-screen-link").firstMatch
421 XCTAssertTrue(accountLink.waitForExistence(timeout: 10))
422 accountLink.tap()
423 434
424 // Real keys render: SSH fingerprints and the PGP key's UID email. 435 // Real keys render: SSH fingerprints and the PGP key's UID email.
425 XCTAssertTrue(app.staticTexts 436 XCTAssertTrue(app.staticTexts
@@ -464,10 +475,7 @@ extension LiveSmokeUITests {
464 /// granted a repo, the grant revoked, the team deleted. Everything 475 /// granted a repo, the grant revoked, the team deleted. Everything
465 /// this test makes, it removes. 476 /// this test makes, it removes.
466 func testOrgFlows() throws { 477 func testOrgFlows() throws {
467 let accountLink = app.descendants(matching: .any) 478 openAccountScreen()
468 .matching(identifier: "account-screen-link").firstMatch
469 XCTAssertTrue(accountLink.waitForExistence(timeout: 10))
470 accountLink.tap()
471 479
472 let orgRow = app.staticTexts["krz"].firstMatch 480 let orgRow = app.staticTexts["krz"].firstMatch
473 XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing") 481 XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
@@ -629,3 +637,66 @@ extension LiveSmokeUITests {
629 .waitForExistence(timeout: 20), "edit not reflected after commit") 637 .waitForExistence(timeout: 20), "edit not reflected after commit")
630 } 638 }
631} 639}
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}