a native ios client for gitbay

client ios swift

https://gitbay.org

design: align native app with web !12

merged cmc wants to merge krz/gitbay-ios:design-polish into main

43 files changed, +603 −359

gitbay/Assets.xcassets/AccentColor.colorset/Contents.json +33 −6
@@ -1,11 +1,38 @@
11 {
2 "colors" : [
2 "colors": [
33 {
4 "idiom" : "universal"
4 "color": {
5 "color-space": "srgb",
6 "components": {
7 "red": "0x00",
8 "green": "0x00",
9 "blue": "0xF0",
10 "alpha": "1.000"
11 }
12 },
13 "idiom": "universal"
14 },
15 {
16 "appearances": [
17 {
18 "appearance": "luminosity",
19 "value": "dark"
20 }
21 ],
22 "color": {
23 "color-space": "srgb",
24 "components": {
25 "red": "0x62",
26 "green": "0x72",
27 "blue": "0xFF",
28 "alpha": "1.000"
29 }
30 },
31 "idiom": "universal"
532 }
633 ],
7 "info" : {
8 "author" : "xcode",
9 "version" : 1
34 "info": {
35 "author": "xcode",
36 "version": 1
1037 }
11}
38}
\ No newline at end of file
gitbay/Assets.xcassets/AppIcon.appiconset/AppIcon.png added

Binary file not shown.

gitbay/Assets.xcassets/AppIcon.appiconset/Contents.json +9 −30
@@ -1,35 +1,14 @@
11 {
2 "images" : [
2 "images": [
33 {
4 "idiom" : "universal",
5 "platform" : "ios",
6 "size" : "1024x1024"
7 },
8 {
9 "appearances" : [
10 {
11 "appearance" : "luminosity",
12 "value" : "dark"
13 }
14 ],
15 "idiom" : "universal",
16 "platform" : "ios",
17 "size" : "1024x1024"
18 },
19 {
20 "appearances" : [
21 {
22 "appearance" : "luminosity",
23 "value" : "tinted"
24 }
25 ],
26 "idiom" : "universal",
27 "platform" : "ios",
28 "size" : "1024x1024"
4 "filename": "AppIcon.png",
5 "idiom": "universal",
6 "platform": "ios",
7 "size": "1024x1024"
298 }
309 ],
31 "info" : {
32 "author" : "xcode",
33 "version" : 1
10 "info": {
11 "author": "xcode",
12 "version": 1
3413 }
35}
14}
\ No newline at end of file
gitbay/Dashboard/DashboardModels.swift +24 −4
@@ -1,18 +1,38 @@
11 import Foundation
22
3/// The `dashboard` command: the whole account aggregate in one read.
4/// `internal/control/dashboard.go` arrays are always present.
3/// The `dashboard` command: the same account aggregate as the web
4/// dashboard, in one read. Newer arrays default empty so an updated app
5/// can still talk to a server from before the dashboard parity contract.
56 nonisolated struct DashboardData: Decodable, Sendable, Hashable {
7 let reviewQueue: [DashboardItem]
8 let assignedIssues: [DashboardItem]
9 let openMRs: [DashboardItem]
10 let openIssues: [DashboardItem]
611 /// Pinned repos share `repo list`'s row shape.
712 let pinned: [RepoSummary]
8 let openMRs: [DashboardItem]
9 let assignedIssues: [DashboardItem]
13 let recentActivity: [FeedEvent]
14 /// Retained by the CLI for compatibility; builds are not a web
15 /// dashboard section.
1016 let builds: [DashboardBuild]
1117
1218 enum CodingKeys: String, CodingKey {
1319 case pinned, builds
20 case reviewQueue = "review_queue"
1421 case openMRs = "open_mrs"
1522 case assignedIssues = "assigned_issues"
23 case openIssues = "open_issues"
24 case recentActivity = "recent_activity"
25 }
26
27 init(from decoder: any Decoder) throws {
28 let values = try decoder.container(keyedBy: CodingKeys.self)
29 reviewQueue = try values.decodeIfPresent([DashboardItem].self, forKey: .reviewQueue) ?? []
30 assignedIssues = try values.decodeIfPresent([DashboardItem].self, forKey: .assignedIssues) ?? []
31 openMRs = try values.decodeIfPresent([DashboardItem].self, forKey: .openMRs) ?? []
32 openIssues = try values.decodeIfPresent([DashboardItem].self, forKey: .openIssues) ?? []
33 pinned = try values.decodeIfPresent([RepoSummary].self, forKey: .pinned) ?? []
34 recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? []
35 builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? []
1636 }
1737 }
1838
gitbay/Dashboard/DashboardViewModel.swift +2 −2
@@ -1,8 +1,8 @@
11 import Foundation
22 import Observation
33
4/// "What needs me": pinned repos, open MRs, assigned issues, recent
5/// builds one `dashboard` read. This replaced a per-repo fan-out that
4/// The web dashboard contract attention queues, open work, pins, and
5/// recent activity in one `dashboard` read. This replaced a per-repo fan-out that
66 /// drained the rate bucket at 64 of 66 repos; krz/gitbay#41 added the
77 /// aggregate.
88 @Observable
gitbay/Discovery/FeedEvent.swift +24 −9
@@ -20,33 +20,48 @@ nonisolated struct FeedEvent: Decodable, Sendable, Hashable, Identifiable {
2020 let sha: String?
2121 let tag: String?
2222 let title: String?
23 let job: String?
2324 }
2425
25 /// "opened !4", "build #56 succeeded" the verb phrase after the actor.
26 /// The same verb and reference phrasing as the web dashboard.
2627 var phrase: String {
2728 let n = data?.number.map(String.init) ?? ""
2829 switch kind {
29 case "mr.created": return "opened !\(n)"
30 case "mr.created": return "opened merge request !\(n)"
3031 case "mr.merged": return "merged !\(n)"
31 case "mr.closed": return "closed !\(n)"
32 case "issue.created": return "opened #\(n)"
33 case "issue.closed": return "closed #\(n)"
34 case "issue.open": return "reopened #\(n)"
32 case "mr.commented": return "commented on !\(n)"
33 case "mr.closed": return "closed merge request !\(n)"
34 case "issue.created": return "opened issue #\(n)"
35 case "issue.closed": return "closed issue #\(n)"
36 case "issue.reopened": return "reopened issue #\(n)"
3537 case "issue.commented": return "commented on #\(n)"
36 case "build.success": return "build #\(n) succeeded"
37 case "build.failure": return "build #\(n) failed"
38 case "build.success": return "build success \(data?.job ?? "")"
39 case "build.failure": return "build failure \(data?.job ?? "")"
3840 case "release.created": return "released \(data?.tag ?? "")"
3941 case "push": return "pushed"
42 case let value where value.hasPrefix("issue."):
43 return "issue \(value.dropFirst("issue.".count)) #\(n)"
44 case let value where value.hasPrefix("mr."):
45 return "merge request \(value.dropFirst("mr.".count)) !\(n)"
46 case let value where value.hasPrefix("build."):
47 return "build \(value.dropFirst("build.".count)) \(data?.job ?? "")"
48 case let value where value.hasPrefix("repo."):
49 return "repository \(value.dropFirst("repo.".count))"
4050 default: return kind
4151 }
4252 }
4353
54 var displayActor: String {
55 guard let actor, !actor.isEmpty else { return "gitbay" }
56 return actor
57 }
58
4459 var icon: (name: String, isPositive: Bool?) {
4560 switch kind {
4661 case "mr.created": ("arrow.triangle.merge", nil)
4762 case "mr.merged": ("arrow.triangle.merge", true)
4863 case "mr.closed": ("xmark.circle", false)
49 case "issue.created", "issue.open": ("smallcircle.filled.circle", nil)
64 case "issue.created", "issue.open", "issue.reopened": ("smallcircle.filled.circle", nil)
5065 case "issue.closed": ("checkmark.circle", true)
5166 case "issue.commented": ("bubble.left", nil)
5267 case "build.success": ("checkmark.circle.fill", true)
gitbay/Fonts/AtkinsonMono-Regular.ttf added

Binary file not shown.

gitbay/Fonts/AtkinsonMono-Semibold.ttf added

Binary file not shown.

gitbay/Fonts/AtkinsonNext-Bold.ttf added

Binary file not shown.

gitbay/Fonts/AtkinsonNext-Italic.ttf added

Binary file not shown.

gitbay/Fonts/AtkinsonNext-Regular.ttf added

Binary file not shown.

gitbay/Fonts/AtkinsonNext-Semibold.ttf added

Binary file not shown.

gitbay/Fonts/OFL-LICENSE.txt added +6
@@ -0,0 +1,6 @@
1Atkinson Hyperlegible Next and Atkinson Hyperlegible Mono
2Copyright 2020-2025 Braille Institute of America, Inc.
3Licensed under the SIL Open Font License, Version 1.1.
4https://openfontlicense.org
5Bundled unmodified apart from static instancing of the variable weight
6axis, matching the faces gitbay's web UI serves.
gitbay/Theme/Theme.swift added +119
@@ -0,0 +1,119 @@
1import SwiftUI
2import UIKit
3import CoreText
4
5/// The web UI's design tokens (`internal/web/static/style.css` in
6/// krz/gitbay), so the two surfaces read as one product. Every pair is
7/// the stylesheet's light/dark value for the same variable.
8extension Color {
9
10 nonisolated private static func paired(_ light: UInt32, _ dark: UInt32) -> Color {
11 Color(UIColor { traits in
12 UIColor(hex: traits.userInterfaceStyle == .dark ? dark : light)
13 })
14 }
15
16 /// --accent: links and focus.
17 nonisolated static let gbAccent = paired(0x0000F0, 0x6272FF)
18 /// --ok: open things, passing builds, verified signatures.
19 nonisolated static let gbOK = paired(0x0A7D3C, 0x3FCE7A)
20 /// --bad: closed without merging, failures, bad signatures.
21 nonisolated static let gbBad = paired(0xC62828, 0xFF6B6B)
22 /// --done: merged.
23 nonisolated static let gbDone = paired(0x6B21A8, 0xC084FC)
24 /// --warn: text-sized warnings.
25 nonisolated static let gbWarn = paired(0xC2410C, 0xFF6B3D)
26 /// --mark: the brand orange, bars and underlines.
27 nonisolated static let gbMark = paired(0xE4572E, 0xFF6B3D)
28 /// --diff-add / --diff-del: row grounds in diffs.
29 nonisolated static let gbDiffAdd = paired(0xE4F6EA, 0x0D2A18)
30 nonisolated static let gbDiffDel = paired(0xFDEAEA, 0x2C1113)
31 /// --code-bg: code blocks.
32 nonisolated static let gbCodeBackground = paired(0xF5F5F5, 0x050505)
33 /// --fill-subtle: hunk headers and quiet fills.
34 nonisolated static let gbFillSubtle = paired(0xECECEC, 0x161616)
35}
36
37extension UIColor {
38 nonisolated convenience init(hex: UInt32) {
39 self.init(
40 red: CGFloat((hex >> 16) & 0xFF) / 255,
41 green: CGFloat((hex >> 8) & 0xFF) / 255,
42 blue: CGFloat(hex & 0xFF) / 255,
43 alpha: 1
44 )
45 }
46}
47
48/// The web's chip shape: --r-pill is 2px, not a capsule.
49nonisolated let gbChipShape = RoundedRectangle(cornerRadius: 2)
50
51/// Atkinson Hyperlegible, the faces the web serves, scaled with Dynamic
52/// Type. Falls back to the system face wherever the fonts fail to
53/// register.
54extension Font {
55
56 nonisolated static func gbSans(_ style: TextStyle) -> Font {
57 .custom("Atkinson Hyperlegible Next", size: gbSize(style), relativeTo: style)
58 }
59
60 nonisolated static func gbMono(_ style: TextStyle) -> Font {
61 .custom("Atkinson Hyperlegible Mono", size: gbSize(style), relativeTo: style)
62 }
63
64 /// Base sizes tracking the system text styles.
65 nonisolated private static func gbSize(_ style: TextStyle) -> CGFloat {
66 switch style {
67 case .largeTitle: 34
68 case .title: 28
69 case .title2: 22
70 case .title3: 20
71 case .headline: 17
72 case .body: 17
73 case .callout: 16
74 case .subheadline: 15
75 case .footnote: 13
76 case .caption: 12
77 case .caption2: 11
78 @unknown default: 17
79 }
80 }
81}
82
83/// Registers the bundled faces once at launch. Registration failure is
84/// survivable: Font.custom falls back to the system face.
85enum GitbayFonts {
86 static func register() {
87 let names = [
88 "AtkinsonNext-Regular", "AtkinsonNext-Bold", "AtkinsonNext-Semibold",
89 "AtkinsonNext-Italic", "AtkinsonMono-Regular", "AtkinsonMono-Semibold",
90 ]
91 let urls = names.compactMap {
92 Bundle.main.url(forResource: $0, withExtension: "ttf")
93 }
94 guard !urls.isEmpty else { return }
95 CTFontManagerRegisterFontURLs(urls as CFArray, .process, true, nil)
96 }
97}
98
99/// The web's `.chip` recipe: state color, a 7% tint ground, a 35% tint
100/// border, 2px radius.
101struct GBChip: View {
102 let text: String
103 let color: Color
104
105 init(_ text: String, _ color: Color) {
106 self.text = text
107 self.color = color
108 }
109
110 var body: some View {
111 Text(text)
112 .font(.gbSans(.caption2).weight(.medium))
113 .foregroundStyle(color)
114 .padding(.horizontal, 7)
115 .padding(.vertical, 1)
116 .background(color.opacity(0.07), in: gbChipShape)
117 .overlay(gbChipShape.stroke(color.opacity(0.35), lineWidth: 1))
118 }
119}
gitbay/Views/Account/AccountView.swift +18 −18
@@ -25,13 +25,13 @@ struct AccountView: View {
2525 NavigationLink(value: OrgRoute.org(membership.org)) {
2626 HStack {
2727 Label(membership.org, systemImage: "building.2")
28 .font(.subheadline)
28 .font(.gbSans(.subheadline))
2929 Spacer()
3030 Text(membership.role)
31 .font(.caption)
31 .font(.gbSans(.caption))
3232 .padding(.horizontal, 6)
3333 .padding(.vertical, 2)
34 .background(.quaternary, in: Capsule())
34 .background(.quaternary, in: gbChipShape)
3535 }
3636 }
3737 }
@@ -45,15 +45,15 @@ struct AccountView: View {
4545 if let error = model.actionError {
4646 Section {
4747 Label(error, systemImage: "hand.raised")
48 .foregroundStyle(.orange)
49 .font(.subheadline)
48 .foregroundStyle(Color.gbWarn)
49 .font(.gbSans(.subheadline))
5050 }
5151 }
5252 if let notice = model.notice {
5353 Section {
5454 Label(notice, systemImage: "envelope")
5555 .foregroundStyle(.secondary)
56 .font(.subheadline)
56 .font(.gbSans(.subheadline))
5757 }
5858 }
5959 emailSection
@@ -131,7 +131,7 @@ struct AccountView: View {
131131 ForEach(keys) { key in
132132 VStack(alignment: .leading, spacing: 2) {
133133 Text(key.fingerprint)
134 .font(.caption.monospaced())
134 .font(.gbMono(.caption))
135135 .lineLimit(1)
136136 .truncationMode(.middle)
137137 HStack(spacing: 6) {
@@ -139,9 +139,9 @@ struct AccountView: View {
139139 Text(key.scope)
140140 .padding(.horizontal, 5)
141141 .padding(.vertical, 1)
142 .background(.quaternary, in: Capsule())
142 .background(.quaternary, in: gbChipShape)
143143 }
144 .font(.caption2)
144 .font(.gbSans(.caption2))
145145 .foregroundStyle(.secondary)
146146 }
147147 .swipeActions {
@@ -154,7 +154,7 @@ struct AccountView: View {
154154 addingSSH = true
155155 } label: {
156156 Label("Add SSH Key", systemImage: "plus")
157 .font(.subheadline)
157 .font(.gbSans(.subheadline))
158158 }
159159 .accessibilityIdentifier("add-ssh-key")
160160 } header: {
@@ -169,12 +169,12 @@ struct AccountView: View {
169169 ForEach(keys) { key in
170170 VStack(alignment: .leading, spacing: 2) {
171171 Text(key.fingerprint)
172 .font(.caption.monospaced())
172 .font(.gbMono(.caption))
173173 .lineLimit(1)
174174 .truncationMode(.middle)
175175 if !key.emailList.isEmpty {
176176 Text(key.emailList.joined(separator: ", "))
177 .font(.caption2)
177 .font(.gbSans(.caption2))
178178 .foregroundStyle(.secondary)
179179 }
180180 }
@@ -188,7 +188,7 @@ struct AccountView: View {
188188 addingPGP = true
189189 } label: {
190190 Label("Add PGP Key", systemImage: "plus")
191 .font(.subheadline)
191 .font(.gbSans(.subheadline))
192192 }
193193 .accessibilityIdentifier("add-pgp-key")
194194 } header: {
@@ -211,7 +211,7 @@ struct AccountView: View {
211211 emailAddress = ""
212212 Task { await model.addEmail(address) }
213213 }
214 .font(.caption)
214 .font(.gbSans(.caption))
215215 .disabled(!emailAddress.contains("@") || model.working)
216216 }
217217 HStack {
@@ -224,7 +224,7 @@ struct AccountView: View {
224224 verifyCode = ""
225225 Task { await model.verifyEmail(code: code) }
226226 }
227 .font(.caption)
227 .font(.gbSans(.caption))
228228 .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
229229 .accessibilityIdentifier("email-verify")
230230 }
@@ -256,7 +256,7 @@ private struct KeyPasteSheet: View {
256256 Section {
257257 TextEditor(text: $text)
258258 .frame(minHeight: 120)
259 .font(.caption.monospaced())
259 .font(.gbMono(.caption))
260260 .autocorrectionDisabled()
261261 .textInputAutocapitalization(.never)
262262 .accessibilityIdentifier("key-paste-text")
@@ -274,8 +274,8 @@ private struct KeyPasteSheet: View {
274274 if let errorMessage {
275275 Section {
276276 Label(errorMessage, systemImage: "exclamationmark.triangle")
277 .foregroundStyle(.red)
278 .font(.subheadline)
277 .foregroundStyle(Color.gbBad)
278 .font(.gbSans(.subheadline))
279279 }
280280 }
281281 }
gitbay/Views/Builds/BuildListView.swift +11 −11
@@ -15,8 +15,8 @@ struct BuildListView: View {
1515 if let error = model.actionError {
1616 Section {
1717 Label(error, systemImage: "hand.raised")
18 .foregroundStyle(.orange)
19 .font(.subheadline)
18 .foregroundStyle(Color.gbWarn)
19 .font(.gbSans(.subheadline))
2020 }
2121 }
2222 ForEach(model.state.value ?? []) { build in
@@ -65,23 +65,23 @@ private struct BuildRow: View {
6565 .foregroundStyle(color)
6666 VStack(alignment: .leading, spacing: 2) {
6767 Text("#\(build.number) \(build.job)")
68 .font(.subheadline.weight(.medium))
68 .font(.gbSans(.subheadline).weight(.medium))
6969 HStack(spacing: 6) {
7070 Text(build.ref)
7171 Text(build.shortSHA)
72 .font(.caption.monospaced())
72 .font(.gbMono(.caption))
7373 }
74 .font(.caption)
74 .font(.gbSans(.caption))
7575 .foregroundStyle(.secondary)
7676 }
7777 Spacer()
7878 VStack(alignment: .trailing, spacing: 2) {
7979 Text(build.status)
80 .font(.caption.weight(.medium))
80 .font(.gbSans(.caption).weight(.medium))
8181 .foregroundStyle(color)
8282 Text(build.createdAt, format: .relative(presentation: .named))
83 .font(.caption)
84 .foregroundStyle(.tertiary)
83 .font(.gbSans(.caption))
84 .foregroundStyle(.secondary)
8585 }
8686 }
8787 .padding(.vertical, 2)
@@ -99,9 +99,9 @@ private struct BuildRow: View {
9999
100100 private var color: Color {
101101 switch build.status {
102 case "success": .green
103 case "failure", "error": .red
104 case "running", "queued", "pending": .orange
102 case "success": .gbOK
103 case "failure", "error": .gbBad
104 case "running", "queued", "pending": .gbWarn
105105 default: .secondary
106106 }
107107 }
gitbay/Views/Builds/BuildLogView.swift +1 −1
@@ -25,7 +25,7 @@ struct BuildLogView: View {
2525 } else {
2626 ScrollView([.horizontal, .vertical]) {
2727 Text(log)
28 .font(.caption2.monospaced())
28 .font(.gbMono(.caption2))
2929 .frame(maxWidth: .infinity, alignment: .leading)
3030 .padding(12)
3131 .textSelection(.enabled)
gitbay/Views/Dashboard/DashboardView.swift +83 −93
@@ -1,6 +1,7 @@
11 import SwiftUI
22
3/// What needs me, in one read.
3/// The native representation of `gitbay dashboard`, ordered and phrased
4/// like the logged-in web dashboard.
45 struct DashboardView: View {
56
67 @State private var model: DashboardViewModel
@@ -12,20 +13,28 @@ struct DashboardView: View {
1213 var body: some View {
1314 List {
1415 if let data = model.state.value {
15 if !data.pinned.isEmpty {
16 pinnedSection(data.pinned)
17 }
1816 itemSection(
19 "Needs review", items: data.openMRs,
20 empty: "No open merge requests.",
21 marker: "!"
17 "Waiting on your review", items: data.reviewQueue,
18 empty: "Nothing waiting on you", marker: "!"
19 ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
20
21 itemSection(
22 "Assigned to you", items: data.assignedIssues,
23 empty: "Nothing assigned to you", marker: "#"
24 ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
25
26 itemSection(
27 "Open merge requests", items: data.openMRs,
28 empty: "No open merge requests", marker: "!"
2229 ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
30
2331 itemSection(
24 "Assigned to me", items: data.assignedIssues,
25 empty: "No assigned issues.",
26 marker: "#"
32 "Open issues", items: data.openIssues,
33 empty: "No open issues", marker: "#"
2734 ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
28 buildsSection(data.builds)
35
36 pinnedSection(data.pinned)
37 activitySection(data.recentActivity)
2938 }
3039 }
3140 .overlay { LoadStateOverlay(state: model.state) }
@@ -43,33 +52,6 @@ struct DashboardView: View {
4352 .refreshable { await model.load() }
4453 }
4554
46 private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
47 Section("Pinned") {
48 ForEach(pinned) { repo in
49 NavigationLink(value: RepoRoute.repo(repo.path)) {
50 VStack(alignment: .leading, spacing: 2) {
51 HStack(spacing: 6) {
52 Text(repo.path)
53 .font(.subheadline.weight(.medium))
54 .lineLimit(1)
55 if repo.isPrivate {
56 Image(systemName: "lock.fill")
57 .font(.caption2)
58 .foregroundStyle(.secondary)
59 }
60 }
61 if let description = repo.description, !description.isEmpty {
62 Text(description)
63 .font(.caption)
64 .foregroundStyle(.secondary)
65 .lineLimit(1)
66 }
67 }
68 }
69 }
70 }
71 }
72
7355 private func itemSection(
7456 _ title: String,
7557 items: [DashboardItem],
@@ -77,86 +59,94 @@ struct DashboardView: View {
7759 marker: String,
7860 route: @escaping (DashboardItem) -> some Hashable
7961 ) -> some View {
80 Section(title) {
62 Section {
8163 if items.isEmpty {
8264 Text(empty)
83 .font(.subheadline)
65 .font(.gbSans(.subheadline))
8466 .foregroundStyle(.secondary)
8567 } else {
8668 ForEach(items) { item in
8769 NavigationLink(value: route(item)) {
88 VStack(alignment: .leading, spacing: 2) {
89 Text(item.repo)
90 .font(.caption)
91 .foregroundStyle(.secondary)
92 HStack(alignment: .firstTextBaseline, spacing: 6) {
93 Text(marker + String(item.number))
94 .font(.caption.monospaced())
95 .foregroundStyle(.secondary)
96 Text(item.title)
97 .font(.subheadline.weight(.medium))
98 .lineLimit(2)
99 }
100 HStack(spacing: 6) {
101 Text("by \(item.author)")
102 Text(item.updatedAt, format: .relative(presentation: .named))
103 .foregroundStyle(.tertiary)
104 }
105 .font(.caption)
106 .foregroundStyle(.secondary)
107 }
70 dashboardItem(item, marker: marker)
10871 }
10972 }
11073 }
74 } header: {
75 sectionHeader(title, count: items.count)
11176 }
11277 }
11378
114 private func buildsSection(_ builds: [DashboardBuild]) -> some View {
115 Section("Recent builds") {
116 if builds.isEmpty {
117 Text("No recent builds.")
118 .font(.subheadline)
79 private func dashboardItem(_ item: DashboardItem, marker: String) -> some View {
80 VStack(alignment: .leading, spacing: 3) {
81 Text(item.title)
82 .font(.gbSans(.subheadline).weight(.semibold))
83 .lineLimit(2)
84 HStack(spacing: 4) {
85 Text(item.repo + marker + String(item.number))
86 .font(.gbSans(.caption))
87 Text("·")
88 Text(item.author)
89 .foregroundStyle(Color.gbAccent)
90 Text("·")
91 Text(item.updatedAt, format: .relative(presentation: .named))
92 if item.state == "source_gone" {
93 Text("·")
94 GBChip("source gone", .secondary)
95 }
96 }
97 .font(.gbSans(.caption))
98 .foregroundStyle(.secondary)
99 .lineLimit(1)
100 }
101 .padding(.vertical, 2)
102 }
103
104 private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
105 Section {
106 if pinned.isEmpty {
107 Text("Pin a repository to keep it here")
108 .font(.gbSans(.subheadline))
119109 .foregroundStyle(.secondary)
120110 } else {
121 ForEach(builds) { build in
122 NavigationLink(value: BuildRoute.log(repo: build.repo, number: build.number)) {
123 HStack(spacing: 8) {
124 Image(systemName: buildIcon(build.status))
125 .foregroundStyle(buildColor(build.status))
126 VStack(alignment: .leading, spacing: 2) {
127 Text(build.repo)
128 .font(.caption)
129 .foregroundStyle(.secondary)
130 Text("#\(build.number) \(build.job)")
131 .font(.subheadline)
111 ForEach(pinned) { repo in
112 NavigationLink(value: RepoRoute.repo(repo.path)) {
113 HStack(spacing: 6) {
114 Text(repo.path)
115 .font(.gbSans(.subheadline).weight(.medium))
116 .foregroundStyle(Color.gbAccent)
117 if repo.isPrivate {
118 GBChip("Private", .secondary)
132119 }
133 Spacer()
134 Text(build.createdAt, format: .relative(presentation: .named))
135 .font(.caption)
136 .foregroundStyle(.tertiary)
137120 }
138121 }
139122 }
140123 }
124 } header: {
125 Text("Pinned")
141126 }
142127 }
143128
144 private func buildIcon(_ status: String) -> String {
145 switch status {
146 case "success": "checkmark.circle.fill"
147 case "failure", "error": "xmark.circle.fill"
148 case "running": "circle.dotted"
149 case "queued", "pending": "clock"
150 default: "questionmark.circle"
129 private func activitySection(_ events: [FeedEvent]) -> some View {
130 Section {
131 if events.isEmpty {
132 Text("No activity yet")
133 .font(.gbSans(.subheadline))
134 .foregroundStyle(.secondary)
135 } else {
136 ForEach(events) { event in
137 FeedLink(event: event)
138 }
139 }
140 } header: {
141 Text("Recent activity")
151142 }
152143 }
153144
154 private func buildColor(_ status: String) -> Color {
155 switch status {
156 case "success": .green
157 case "failure", "error": .red
158 case "running", "queued", "pending": .orange
159 default: .secondary
145 private func sectionHeader(_ title: String, count: Int) -> some View {
146 HStack(alignment: .firstTextBaseline, spacing: 4) {
147 Text(title)
148 Text(String(count))
149 .foregroundStyle(.secondary)
160150 }
161151 }
162152 }
gitbay/Views/Discovery/FeedView.swift +18 −13
@@ -16,7 +16,7 @@ struct FeedView: View {
1616 var body: some View {
1717 List {
1818 ForEach(list.state.value ?? []) { event in
19 link(for: event)
19 FeedLink(event: event)
2020 }
2121 PageFooter(list: list)
2222 }
@@ -31,8 +31,15 @@ struct FeedView: View {
3131 .refreshable { await list.reload() }
3232 }
3333
34}
35
36/// One feed event linked to the same native destination everywhere it is
37/// represented, including the dashboard's recent-activity section.
38struct FeedLink: View {
39 let event: FeedEvent
40
3441 @ViewBuilder
35 private func link(for event: FeedEvent) -> some View {
42 var body: some View {
3643 switch event.destination {
3744 case .repo(let repo):
3845 NavigationLink(value: RepoRoute.repo(repo)) { FeedRow(event: event) }
@@ -50,7 +57,7 @@ struct FeedView: View {
5057 }
5158 }
5259
53private struct FeedRow: View {
60struct FeedRow: View {
5461 let event: FeedEvent
5562
5663 var body: some View {
@@ -60,30 +67,28 @@ private struct FeedRow: View {
6067 .frame(width: 22)
6168 VStack(alignment: .leading, spacing: 2) {
6269 Text(event.repo)
63 .font(.caption)
70 .font(.gbSans(.caption))
6471 .foregroundStyle(.secondary)
6572 HStack(spacing: 4) {
66 if let actor = event.actor, !actor.isEmpty {
67 Text(actor)
68 .font(.subheadline.weight(.semibold))
69 }
73 Text(event.displayActor)
74 .font(.gbSans(.subheadline).weight(.semibold))
7075 Text(event.phrase)
71 .font(.subheadline)
76 .font(.gbSans(.subheadline))
7277 .lineLimit(1)
7378 }
7479 }
7580 Spacer()
7681 Text(event.createdAt, format: .relative(presentation: .named))
77 .font(.caption)
78 .foregroundStyle(.tertiary)
82 .font(.gbSans(.caption))
83 .foregroundStyle(.secondary)
7984 }
8085 .padding(.vertical, 2)
8186 }
8287
8388 private var color: Color {
8489 switch event.icon.isPositive {
85 case true: .green
86 case false: .red
90 case true: .gbOK
91 case false: .gbBad
8792 default: .secondary
8893 }
8994 }
gitbay/Views/Discovery/GrepView.swift +3 −3
@@ -20,18 +20,18 @@ struct GrepView: View {
2020 )) {
2121 HStack(alignment: .firstTextBaseline, spacing: 8) {
2222 Text(String(match.line))
23 .font(.caption.monospaced())
23 .font(.gbMono(.caption))
2424 .foregroundStyle(.tertiary)
2525 .frame(minWidth: 32, alignment: .trailing)
2626 Text(match.text.trimmingCharacters(in: .whitespaces))
27 .font(.caption.monospaced())
27 .font(.gbMono(.caption))
2828 .lineLimit(2)
2929 }
3030 }
3131 }
3232 } header: {
3333 Text(group.file)
34 .font(.caption.monospaced())
34 .font(.gbMono(.caption))
3535 .textCase(nil)
3636 }
3737 }
gitbay/Views/Discovery/ProfileView.swift +8 −8
@@ -17,24 +17,24 @@ struct ProfileView: View {
1717 HStack(spacing: 8) {
1818 Image(systemName: profile.kind == "org"
1919 ? "building.2" : "person.crop.circle")
20 .font(.title2)
20 .font(.gbSans(.title2))
2121 .foregroundStyle(.secondary)
2222 Text(profile.name)
23 .font(.title3.weight(.semibold))
23 .font(.gbSans(.title3).weight(.semibold))
2424 Text(profile.kind)
25 .font(.caption2)
25 .font(.gbSans(.caption2))
2626 .padding(.horizontal, 6)
2727 .padding(.vertical, 1)
28 .background(.quaternary, in: Capsule())
28 .background(.quaternary, in: gbChipShape)
2929 }
3030 if let description = profile.description, !description.isEmpty {
3131 Text(description)
32 .font(.subheadline)
32 .font(.gbSans(.subheadline))
3333 .foregroundStyle(.secondary)
3434 }
3535 if let website = profile.website, let url = URL(string: website) {
3636 Link(website, destination: url)
37 .font(.caption)
37 .font(.gbSans(.caption))
3838 .lineLimit(1)
3939 }
4040 }
@@ -55,10 +55,10 @@ struct ProfileView: View {
5555 NavigationLink(value: RepoRoute.repo(repo.path)) {
5656 VStack(alignment: .leading, spacing: 2) {
5757 Text(repo.name)
58 .font(.subheadline.weight(.medium))
58 .font(.gbSans(.subheadline).weight(.medium))
5959 if let description = repo.description, !description.isEmpty {
6060 Text(description)
61 .font(.caption)
61 .font(.gbSans(.caption))
6262 .foregroundStyle(.secondary)
6363 .lineLimit(1)
6464 }
gitbay/Views/Issues/IssueListView.swift +9 −9
@@ -75,33 +75,33 @@ private struct IssueRow: View {
7575 VStack(alignment: .leading, spacing: 4) {
7676 HStack(alignment: .firstTextBaseline, spacing: 6) {
7777 Text("#\(issue.number)")
78 .font(.caption.monospaced())
78 .font(.gbMono(.caption))
7979 .foregroundStyle(.secondary)
8080 Text(issue.title)
81 .font(.subheadline.weight(.medium))
81 .font(.gbSans(.subheadline).weight(.medium))
8282 .lineLimit(2)
8383 }
8484 HStack(spacing: 6) {
8585 Image(systemName: issue.isOpen ? "circle" : "checkmark.circle.fill")
86 .font(.caption2)
87 .foregroundStyle(issue.isOpen ? .green : .purple)
86 .font(.gbSans(.caption2))
87 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
8888 ForEach(issue.labels ?? [], id: \.self) { label in
8989 Text(label)
90 .font(.caption2)
90 .font(.gbSans(.caption2))
9191 .padding(.horizontal, 5)
9292 .padding(.vertical, 1)
93 .background(.quaternary, in: Capsule())
93 .background(.quaternary, in: gbChipShape)
9494 }
9595 Spacer()
9696 if let assignees = issue.assignees, !assignees.isEmpty {
9797 Text(assignees.joined(separator: ", "))
98 .font(.caption)
98 .font(.gbSans(.caption))
9999 .foregroundStyle(.secondary)
100100 .lineLimit(1)
101101 }
102102 Text(issue.createdAt, format: .relative(presentation: .named))
103 .font(.caption)
104 .foregroundStyle(.tertiary)
103 .font(.gbSans(.caption))
104 .foregroundStyle(.secondary)
105105 }
106106 }
107107 .padding(.vertical, 2)
gitbay/Views/Issues/IssueView.swift +13 −13
@@ -24,8 +24,8 @@ struct IssueView: View {
2424 if let error = model.actionError {
2525 Section {
2626 Label(error, systemImage: "hand.raised")
27 .foregroundStyle(.orange)
28 .font(.subheadline)
27 .foregroundStyle(Color.gbWarn)
28 .font(.gbSans(.subheadline))
2929 }
3030 }
3131
@@ -68,11 +68,11 @@ struct IssueView: View {
6868 Section {
6969 VStack(alignment: .leading, spacing: 6) {
7070 Text(issue.title)
71 .font(.headline)
71 .font(.gbSans(.headline))
7272 HStack(spacing: 6) {
7373 Label(issue.state, systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill")
74 .font(.caption.weight(.medium))
75 .foregroundStyle(issue.isOpen ? .green : .purple)
74 .font(.gbSans(.caption).weight(.medium))
75 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
7676 Text("by \(issue.author)")
7777 Text(issue.createdAt, format: .relative(presentation: .named))
7878 .foregroundStyle(.tertiary)
@@ -80,7 +80,7 @@ struct IssueView: View {
8080 Label(milestone, systemImage: "flag")
8181 }
8282 }
83 .font(.caption)
83 .font(.gbSans(.caption))
8484 .foregroundStyle(.secondary)
8585 }
8686 .padding(.vertical, 2)
@@ -138,10 +138,10 @@ struct IssueView: View {
138138 } label: {
139139 HStack {
140140 Label(issue.milestone ?? "None", systemImage: "flag")
141 .font(.subheadline)
141 .font(.gbSans(.subheadline))
142142 Spacer()
143143 Image(systemName: "chevron.up.chevron.down")
144 .font(.caption2)
144 .font(.gbSans(.caption2))
145145 .foregroundStyle(.secondary)
146146 }
147147 }
@@ -167,10 +167,10 @@ struct IssueView: View {
167167 }
168168 .disabled(model.working)
169169 }
170 .font(.caption)
170 .font(.gbSans(.caption))
171171 .padding(.horizontal, 8)
172172 .padding(.vertical, 3)
173 .background(.quaternary, in: Capsule())
173 .background(.quaternary, in: gbChipShape)
174174 }
175175 }
176176 }
@@ -183,13 +183,13 @@ struct IssueView: View {
183183 VStack(alignment: .leading, spacing: 4) {
184184 HStack {
185185 Text(comment.author)
186 .font(.caption.weight(.semibold))
186 .font(.gbSans(.caption).weight(.semibold))
187187 Text(comment.createdAt, format: .relative(presentation: .named))
188 .font(.caption)
188 .font(.gbSans(.caption))
189189 .foregroundStyle(.tertiary)
190190 }
191191 MarkdownView(markdown: comment.body)
192 .font(.subheadline)
192 .font(.gbSans(.subheadline))
193193 }
194194 .padding(.vertical, 2)
195195 }
gitbay/Views/MRs/DiffView.swift +16 −14
@@ -55,7 +55,7 @@ private struct FileDiffSection: View {
5555 if !collapsed {
5656 if file.isBinary {
5757 Text("Binary file")
58 .font(.caption)
58 .font(.gbSans(.caption))
5959 .foregroundStyle(.secondary)
6060 } else {
6161 ForEach(file.hunks) { hunk in
@@ -69,20 +69,20 @@ private struct FileDiffSection: View {
6969 } label: {
7070 HStack(spacing: 6) {
7171 Image(systemName: collapsed ? "chevron.right" : "chevron.down")
72 .font(.caption2)
72 .font(.gbSans(.caption2))
7373 Text(file.displayPath)
74 .font(.caption.monospaced().weight(.semibold))
74 .font(.gbMono(.caption).weight(.semibold))
7575 .lineLimit(1)
7676 .truncationMode(.head)
7777 if file.isNew {
78 Text("new").font(.caption2).foregroundStyle(.green)
78 Text("new").font(.gbSans(.caption2)).foregroundStyle(Color.gbOK)
7979 }
8080 if file.isDeleted {
81 Text("deleted").font(.caption2).foregroundStyle(.red)
81 Text("deleted").font(.gbSans(.caption2)).foregroundStyle(Color.gbBad)
8282 }
8383 Spacer()
84 Text("+\(file.additions)").foregroundStyle(.green).font(.caption2)
85 Text("\(file.deletions)").foregroundStyle(.red).font(.caption2)
84 Text("+\(file.additions)").foregroundStyle(Color.gbOK).font(.gbSans(.caption2))
85 Text("\(file.deletions)").foregroundStyle(Color.gbBad).font(.gbSans(.caption2))
8686 }
8787 }
8888 .buttonStyle(.plain)
@@ -99,9 +99,11 @@ private struct HunkView: View {
9999 ScrollView(.horizontal) {
100100 VStack(alignment: .leading, spacing: 0) {
101101 Text(hunk.header)
102 .font(.caption2.monospaced())
103 .foregroundStyle(.blue)
102 .font(.gbMono(.caption2))
103 .foregroundStyle(.secondary)
104104 .padding(.vertical, 2)
105 .frame(maxWidth: .infinity, alignment: .leading)
106 .background(Color.gbFillSubtle)
105107 ForEach(hunk.lines) { line in
106108 LineView(line: line)
107109 }
@@ -129,7 +131,7 @@ private struct LineView: View {
129131 Text(line.text.isEmpty ? " " : line.text)
130132 .foregroundStyle(textColor)
131133 }
132 .font(.caption2.monospaced())
134 .font(.gbMono(.caption2))
133135 .background(background)
134136 }
135137
@@ -143,8 +145,8 @@ private struct LineView: View {
143145
144146 private var markerColor: Color {
145147 switch line.kind {
146 case .addition: .green
147 case .deletion: .red
148 case .addition: .gbOK
149 case .deletion: .gbBad
148150 case .context: .clear
149151 }
150152 }
@@ -155,8 +157,8 @@ private struct LineView: View {
155157
156158 private var background: Color {
157159 switch line.kind {
158 case .addition: .green.opacity(0.12)
159 case .deletion: .red.opacity(0.12)
160 case .addition: .gbDiffAdd
161 case .deletion: .gbDiffDel
160162 case .context: .clear
161163 }
162164 }
gitbay/Views/MRs/MRListView.swift +13 −16
@@ -94,8 +94,8 @@ private struct MRCreateSheet: View {
9494 if let error = model.errorMessage {
9595 Section {
9696 Label(error, systemImage: "exclamationmark.triangle")
97 .foregroundStyle(.red)
98 .font(.subheadline)
97 .foregroundStyle(Color.gbBad)
98 .font(.gbSans(.subheadline))
9999 }
100100 }
101101 }
@@ -146,10 +146,10 @@ struct MRRow: View {
146146 VStack(alignment: .leading, spacing: 4) {
147147 HStack(alignment: .firstTextBaseline, spacing: 6) {
148148 Text("!\(mr.number)")
149 .font(.caption.monospaced())
149 .font(.gbMono(.caption))
150150 .foregroundStyle(.secondary)
151151 Text(mr.title)
152 .font(.subheadline.weight(.medium))
152 .font(.gbSans(.subheadline).weight(.medium))
153153 .lineLimit(2)
154154 }
155155 HStack(spacing: 6) {
@@ -157,13 +157,13 @@ struct MRRow: View {
157157 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
158158 .lineLimit(1)
159159 Image(systemName: "arrow.right")
160 .font(.caption2)
160 .font(.gbSans(.caption2))
161161 Text(mr.targetRef)
162162 Spacer()
163163 Text(mr.createdAt, format: .relative(presentation: .named))
164 .foregroundStyle(.tertiary)
164 .foregroundStyle(.secondary)
165165 }
166 .font(.caption)
166 .font(.gbSans(.caption))
167167 .foregroundStyle(.secondary)
168168 }
169169 .padding(.vertical, 2)
@@ -174,19 +174,16 @@ struct MRStateBadge: View {
174174 let state: String
175175
176176 var body: some View {
177 Text(state.replacingOccurrences(of: "_", with: " "))
178 .font(.caption2.weight(.medium))
179 .padding(.horizontal, 6)
180 .padding(.vertical, 1)
181 .background(color.opacity(0.15), in: Capsule())
182 .foregroundStyle(color)
177 GBChip(state.replacingOccurrences(of: "_", with: " "), color)
183178 }
184179
180 /// The web's chip mapping: open=ok, merged=done, closed=bad,
181 /// source_gone=neutral.
185182 private var color: Color {
186183 switch state {
187 case "open": .green
188 case "merged": .purple
189 case "closed": .red
184 case "open": .gbOK
185 case "merged": .gbDone
186 case "closed": .gbBad
190187 default: .secondary
191188 }
192189 }
gitbay/Views/MRs/MRView.swift +33 −33
@@ -24,8 +24,8 @@ struct MRView: View {
2424 if let error = model.actionError {
2525 Section {
2626 Label(error, systemImage: "hand.raised")
27 .foregroundStyle(.orange)
28 .font(.subheadline)
27 .foregroundStyle(Color.gbWarn)
28 .font(.gbSans(.subheadline))
2929 }
3030 }
3131
@@ -96,23 +96,23 @@ struct MRView: View {
9696 Section {
9797 VStack(alignment: .leading, spacing: 6) {
9898 Text(mr.title)
99 .font(.headline)
99 .font(.gbSans(.headline))
100100 HStack(spacing: 6) {
101101 MRStateBadge(state: mr.state)
102102 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
103103 .lineLimit(1)
104104 Image(systemName: "arrow.right")
105 .font(.caption2)
105 .font(.gbSans(.caption2))
106106 Text(mr.targetRef)
107107 }
108 .font(.caption)
108 .font(.gbSans(.caption))
109109 .foregroundStyle(.secondary)
110110 HStack(spacing: 6) {
111111 Text("by \(mr.author)")
112112 Text(mr.createdAt, format: .relative(presentation: .named))
113113 .foregroundStyle(.tertiary)
114114 }
115 .font(.caption)
115 .font(.gbSans(.caption))
116116 .foregroundStyle(.secondary)
117117 }
118118 .padding(.vertical, 2)
@@ -127,12 +127,12 @@ struct MRView: View {
127127 Spacer()
128128 if let diff = model.diff {
129129 Text("+\(diff.additions)")
130 .foregroundStyle(.green)
130 .foregroundStyle(Color.gbOK)
131131 Text("\(diff.deletions)")
132 .foregroundStyle(.red)
132 .foregroundStyle(Color.gbBad)
133133 }
134134 }
135 .font(.subheadline)
135 .font(.gbSans(.subheadline))
136136 }
137137 }
138138 }
@@ -142,10 +142,10 @@ struct MRView: View {
142142 ForEach(commits) { commit in
143143 HStack(spacing: 8) {
144144 Text(commit.shortSHA)
145 .font(.caption.monospaced())
145 .font(.gbMono(.caption))
146146 .foregroundStyle(.secondary)
147147 Text(commit.subject)
148 .font(.subheadline)
148 .font(.gbSans(.subheadline))
149149 .lineLimit(1)
150150 }
151151 }
@@ -159,10 +159,10 @@ struct MRView: View {
159159 Image(systemName: checkIcon(check.state))
160160 .foregroundStyle(checkColor(check.state))
161161 Text(check.context)
162 .font(.subheadline)
162 .font(.gbSans(.subheadline))
163163 Spacer()
164164 Text(check.state)
165 .font(.caption)
165 .font(.gbSans(.caption))
166166 .foregroundStyle(.secondary)
167167 }
168168 }
@@ -175,16 +175,16 @@ struct MRView: View {
175175 HStack {
176176 Image(systemName: review.verdict == "approve"
177177 ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
178 .foregroundStyle(review.verdict == "approve" ? .green : .orange)
178 .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn)
179179 Text(review.reviewer)
180 .font(.subheadline)
180 .font(.gbSans(.subheadline))
181181 Spacer()
182182 if review.stale {
183183 Text("stale")
184 .font(.caption2)
184 .font(.gbSans(.caption2))
185185 .padding(.horizontal, 5)
186186 .padding(.vertical, 1)
187 .background(.quaternary, in: Capsule())
187 .background(.quaternary, in: gbChipShape)
188188 }
189189 }
190190 }
@@ -205,13 +205,13 @@ struct MRView: View {
205205 VStack(alignment: .leading, spacing: 4) {
206206 HStack {
207207 Text(comment.author)
208 .font(.caption.weight(.semibold))
208 .font(.gbSans(.caption).weight(.semibold))
209209 Text(comment.createdAt, format: .relative(presentation: .named))
210 .font(.caption)
210 .font(.gbSans(.caption))
211211 .foregroundStyle(.tertiary)
212212 }
213213 MarkdownView(markdown: comment.body)
214 .font(.subheadline)
214 .font(.gbSans(.subheadline))
215215 }
216216 .padding(.vertical, 2)
217217 }
@@ -290,9 +290,9 @@ struct MRView: View {
290290
291291 private func checkColor(_ state: String) -> Color {
292292 switch state {
293 case "success": .green
294 case "failure", "error": .red
295 case "pending", "running": .orange
293 case "success": .gbOK
294 case "failure", "error": .gbBad
295 case "pending", "running": .gbWarn
296296 default: .secondary
297297 }
298298 }
@@ -310,23 +310,23 @@ private struct ThreadView: View {
310310 HStack(spacing: 6) {
311311 Image(systemName: thread.isResolved
312312 ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
313 .font(.caption)
314 .foregroundStyle(thread.isResolved ? .green : .orange)
313 .font(.gbSans(.caption))
314 .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn)
315315 Text("\(thread.path):\(thread.line)")
316 .font(.caption.monospaced())
316 .font(.gbMono(.caption))
317317 .lineLimit(1)
318318 if thread.stale {
319319 Text("stale")
320 .font(.caption2)
320 .font(.gbSans(.caption2))
321321 .padding(.horizontal, 5)
322322 .padding(.vertical, 1)
323 .background(.quaternary, in: Capsule())
323 .background(.quaternary, in: gbChipShape)
324324 }
325325 Spacer()
326326 Button(thread.isResolved ? "Reopen" : "Resolve") {
327327 Task { await model.setResolved(thread, !thread.isResolved) }
328328 }
329 .font(.caption)
329 .font(.gbSans(.caption))
330330 .buttonStyle(.bordered)
331331 .disabled(model.working)
332332 }
@@ -334,19 +334,19 @@ private struct ThreadView: View {
334334 VStack(alignment: .leading, spacing: 2) {
335335 HStack {
336336 Text(comment.author)
337 .font(.caption.weight(.semibold))
337 .font(.gbSans(.caption).weight(.semibold))
338338 Text(comment.createdAt, format: .relative(presentation: .named))
339 .font(.caption2)
339 .font(.gbSans(.caption2))
340340 .foregroundStyle(.tertiary)
341341 }
342342 Text(comment.body)
343 .font(.subheadline)
343 .font(.gbSans(.subheadline))
344344 }
345345 }
346346 if !thread.isResolved {
347347 HStack {
348348 TextField("Reply", text: $replyText, axis: .vertical)
349 .font(.subheadline)
349 .font(.gbSans(.subheadline))
350350 .lineLimit(1...4)
351351 Button {
352352 let text = replyText
gitbay/Views/Orgs/OrgView.swift +6 −6
@@ -21,8 +21,8 @@ struct OrgView: View {
2121 if let error = model.actionError {
2222 Section {
2323 Label(error, systemImage: "hand.raised")
24 .foregroundStyle(.orange)
25 .font(.subheadline)
24 .foregroundStyle(Color.gbWarn)
25 .font(.gbSans(.subheadline))
2626 }
2727 }
2828 membersSection(loaded.members)
@@ -72,7 +72,7 @@ struct OrgView: View {
7272 HStack {
7373 NavigationLink(value: RepoRoute.profile(member.user)) {
7474 Text(member.user)
75 .font(.subheadline)
75 .font(.gbSans(.subheadline))
7676 }
7777 Spacer()
7878 Menu {
@@ -80,10 +80,10 @@ struct OrgView: View {
8080 Button("Member") { Task { await model.setRole(member, role: "member") } }
8181 } label: {
8282 Text(member.role)
83 .font(.caption)
83 .font(.gbSans(.caption))
8484 .padding(.horizontal, 6)
8585 .padding(.vertical, 2)
86 .background(.quaternary, in: Capsule())
86 .background(.quaternary, in: gbChipShape)
8787 }
8888 .disabled(model.working)
8989 }
@@ -121,7 +121,7 @@ struct OrgView: View {
121121 ForEach(teams, id: \.self) { team in
122122 NavigationLink(value: OrgRoute.team(org: model.orgName, team: team)) {
123123 Label(team, systemImage: "person.3")
124 .font(.subheadline)
124 .font(.gbSans(.subheadline))
125125 }
126126 .swipeActions {
127127 Button("Delete", role: .destructive) {
gitbay/Views/Orgs/TeamView.swift +6 −6
@@ -18,15 +18,15 @@ struct TeamView: View {
1818 if let error = model.actionError {
1919 Section {
2020 Label(error, systemImage: "hand.raised")
21 .foregroundStyle(.orange)
22 .font(.subheadline)
21 .foregroundStyle(Color.gbWarn)
22 .font(.gbSans(.subheadline))
2323 }
2424 }
2525
2626 Section("Members") {
2727 ForEach(loaded.members ?? [], id: \.self) { user in
2828 Text(user)
29 .font(.subheadline)
29 .font(.gbSans(.subheadline))
3030 .swipeActions {
3131 Button("Remove", role: .destructive) {
3232 Task { await model.removeMember(user) }
@@ -53,14 +53,14 @@ struct TeamView: View {
5353 ForEach(loaded.grants ?? []) { grant in
5454 HStack {
5555 Text(grant.repo)
56 .font(.subheadline)
56 .font(.gbSans(.subheadline))
5757 .lineLimit(1)
5858 Spacer()
5959 Text(grant.role)
60 .font(.caption)
60 .font(.gbSans(.caption))
6161 .padding(.horizontal, 6)
6262 .padding(.vertical, 2)
63 .background(.quaternary, in: Capsule())
63 .background(.quaternary, in: gbChipShape)
6464 }
6565 .swipeActions {
6666 Button("Revoke", role: .destructive) {
gitbay/Views/Releases/ReleaseListView.swift +6 −6
@@ -18,20 +18,20 @@ struct ReleaseListView: View {
1818 NavigationLink(value: ReleaseRoute.release(repo: model.repoPath, tag: release.tag)) {
1919 VStack(alignment: .leading, spacing: 3) {
2020 Text(release.title.isEmpty ? release.tag : release.title)
21 .font(.subheadline.weight(.medium))
21 .font(.gbSans(.subheadline).weight(.medium))
2222 .lineLimit(2)
2323 HStack(spacing: 6) {
2424 Text(release.tag)
25 .font(.caption.monospaced())
25 .font(.gbMono(.caption))
2626 .foregroundStyle(.secondary)
2727 if let author = release.author {
2828 Text("by \(author)")
29 .font(.caption)
29 .font(.gbSans(.caption))
3030 .foregroundStyle(.secondary)
3131 }
3232 Spacer()
3333 Text(release.createdAt, format: .relative(presentation: .named))
34 .font(.caption)
34 .font(.gbSans(.caption))
3535 .foregroundStyle(.tertiary)
3636 }
3737 }
@@ -101,8 +101,8 @@ private struct ReleaseCreateSheet: View {
101101 if let error = model.createError {
102102 Section {
103103 Label(error, systemImage: "exclamationmark.triangle")
104 .foregroundStyle(.red)
105 .font(.subheadline)
104 .foregroundStyle(Color.gbBad)
105 .font(.gbSans(.subheadline))
106106 }
107107 }
108108 }
gitbay/Views/Releases/ReleaseView.swift +8 −8
@@ -19,24 +19,24 @@ struct ReleaseView: View {
1919 if let error = model.actionError {
2020 Section {
2121 Label(error, systemImage: "hand.raised")
22 .foregroundStyle(.orange)
23 .font(.subheadline)
22 .foregroundStyle(Color.gbWarn)
23 .font(.gbSans(.subheadline))
2424 }
2525 }
2626 Section {
2727 VStack(alignment: .leading, spacing: 6) {
2828 Text(release.title.isEmpty ? release.tag : release.title)
29 .font(.headline)
29 .font(.gbSans(.headline))
3030 HStack(spacing: 6) {
3131 Text(release.tag)
32 .font(.caption.monospaced())
32 .font(.gbMono(.caption))
3333 if let author = release.author {
3434 Text("by \(author)")
3535 }
3636 Text(release.createdAt, format: .relative(presentation: .named))
3737 .foregroundStyle(.tertiary)
3838 }
39 .font(.caption)
39 .font(.gbSans(.caption))
4040 .foregroundStyle(.secondary)
4141 }
4242 .padding(.vertical, 2)
@@ -56,16 +56,16 @@ struct ReleaseView: View {
5656 HStack {
5757 VStack(alignment: .leading, spacing: 2) {
5858 Text(asset.name)
59 .font(.caption.monospaced())
59 .font(.gbMono(.caption))
6060 .foregroundStyle(.primary)
6161 .lineLimit(1)
6262 Text(String(asset.sha256.prefix(16)))
63 .font(.caption2.monospaced())
63 .font(.gbMono(.caption2))
6464 .foregroundStyle(.tertiary)
6565 }
6666 Spacer()
6767 Text(asset.size.formatted(.byteCount(style: .file)))
68 .font(.caption)
68 .font(.gbSans(.caption))
6969 .foregroundStyle(.secondary)
7070 Image(systemName: "arrow.down.circle")
7171 .foregroundStyle(.secondary)
gitbay/Views/Repos/FileView.swift +5 −5
@@ -40,7 +40,7 @@ struct FileView: View {
4040 "Truncated — showing the first \(Int64(file.size).formatted(.byteCount(style: .file))).",
4141 systemImage: "scissors"
4242 )
43 .font(.caption)
43 .font(.gbSans(.caption))
4444 .frame(maxWidth: .infinity, alignment: .leading)
4545 .padding(8)
4646 .background(.yellow.opacity(0.15))
@@ -64,13 +64,13 @@ private struct CodeScrollView: View {
6464 .padding(12)
6565 .frame(maxWidth: .infinity, alignment: .leading)
6666 }
67 .font(.caption.monospaced())
67 .font(.gbMono(.caption))
6868 }
6969
7070 private var code: some View {
71 let font = UIFont.monospacedSystemFont(
72 ofSize: UIFont.preferredFont(forTextStyle: .caption1).pointSize, weight: .regular
73 )
71 let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize
72 let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size)
73 ?? .monospacedSystemFont(ofSize: size, weight: .regular)
7474 let highlighter = SyntaxHighlighter(colorScheme: colorScheme)
7575 if let highlighted = highlighter.highlight(text, fileName: fileName, font: font) {
7676 return Text(AttributedString(highlighted))
gitbay/Views/Repos/LogView.swift +11 −11
@@ -28,21 +28,21 @@ struct CommitRow: View {
2828 var body: some View {
2929 VStack(alignment: .leading, spacing: 4) {
3030 Text(commit.subject)
31 .font(.subheadline.weight(.medium))
31 .font(.gbSans(.subheadline).weight(.medium))
3232 .lineLimit(2)
3333 HStack(spacing: 6) {
3434 Text(commit.shortSHA)
35 .font(.caption.monospaced())
35 .font(.gbMono(.caption))
3636 .foregroundStyle(.secondary)
3737 SignatureBadge(signature: commit.signature)
3838 Spacer()
3939 Text(commit.authorName)
40 .font(.caption)
40 .font(.gbSans(.caption))
4141 .foregroundStyle(.secondary)
4242 .lineLimit(1)
4343 Text(commit.date, format: .relative(presentation: .named))
44 .font(.caption)
45 .foregroundStyle(.tertiary)
44 .font(.gbSans(.caption))
45 .foregroundStyle(.secondary)
4646 }
4747 }
4848 .padding(.vertical, 2)
@@ -61,7 +61,7 @@ struct SignatureBadge: View {
6161 } icon: {
6262 Image(systemName: icon)
6363 }
64 .font(.caption2)
64 .font(.gbSans(.caption2))
6565 .foregroundStyle(color)
6666 .labelStyle(.titleAndIcon)
6767 }
@@ -74,17 +74,17 @@ struct SignatureBadge: View {
7474 case .unsigned:
7575 nil
7676 case .verified:
77 ("checkmark.seal.fill", .green, signature.signer ?? "verified")
77 ("checkmark.seal.fill", .gbOK, signature.signer ?? "verified")
7878 case .signedUnknownKey:
7979 ("questionmark.diamond", .secondary, "unknown key")
8080 case .signedEmailMismatch:
81 ("exclamationmark.triangle", .orange, "email mismatch")
81 ("exclamationmark.triangle", .gbWarn, "email mismatch")
8282 case .signedKeyExpired:
83 ("clock.badge.exclamationmark", .orange, "key expired")
83 ("clock.badge.exclamationmark", .gbWarn, "key expired")
8484 case .signedKeyRevoked:
85 ("xmark.seal", .red, "key revoked")
85 ("xmark.seal", .gbBad, "key revoked")
8686 case .badSignature:
87 ("xmark.seal.fill", .red, "bad signature")
87 ("xmark.seal.fill", .gbBad, "bad signature")
8888 case .unrecognized(let state):
8989 ("questionmark.circle", .secondary, state)
9090 }
gitbay/Views/Repos/MarkdownView.swift +2 −2
@@ -119,10 +119,10 @@ struct MarkdownView: View {
119119 case .code(let code):
120120 ScrollView(.horizontal) {
121121 Text(code)
122 .font(.caption.monospaced())
122 .font(.gbMono(.caption))
123123 .padding(10)
124124 }
125 .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
125 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
126126 case .quote(let text):
127127 HStack(spacing: 10) {
128128 RoundedRectangle(cornerRadius: 2)
gitbay/Views/Repos/RepoListView.swift +7 −7
@@ -81,8 +81,8 @@ private struct RepoCreateSheet: View {
8181 if let error = model.errorMessage {
8282 Section {
8383 Label(error, systemImage: "exclamationmark.triangle")
84 .foregroundStyle(.red)
85 .font(.subheadline)
84 .foregroundStyle(Color.gbBad)
85 .font(.gbSans(.subheadline))
8686 }
8787 }
8888 }
@@ -128,24 +128,24 @@ private struct RepoRow: View {
128128 VStack(alignment: .leading, spacing: 3) {
129129 HStack(spacing: 6) {
130130 Text(repo.path)
131 .font(.body.weight(.medium))
131 .font(.gbSans(.body).weight(.medium))
132132 .lineLimit(1)
133133 if repo.isPrivate {
134134 Image(systemName: "lock.fill")
135 .font(.caption2)
135 .font(.gbSans(.caption2))
136136 .foregroundStyle(.secondary)
137137 }
138138 if repo.isArchived {
139139 Text("archived")
140 .font(.caption2)
140 .font(.gbSans(.caption2))
141141 .padding(.horizontal, 5)
142142 .padding(.vertical, 1)
143 .background(.quaternary, in: Capsule())
143 .background(.quaternary, in: gbChipShape)
144144 }
145145 }
146146 if let description = repo.description, !description.isEmpty {
147147 Text(description)
148 .font(.subheadline)
148 .font(.gbSans(.subheadline))
149149 .foregroundStyle(.secondary)
150150 .lineLimit(2)
151151 }
gitbay/Views/Repos/RepoSettingsView.swift +8 −8
@@ -21,8 +21,8 @@ struct RepoSettingsView: View {
2121 if let error = model.actionError {
2222 Section {
2323 Label(error, systemImage: "hand.raised")
24 .foregroundStyle(.orange)
25 .font(.subheadline)
24 .foregroundStyle(Color.gbWarn)
25 .font(.gbSans(.subheadline))
2626 }
2727 }
2828 aboutSection(loaded)
@@ -59,7 +59,7 @@ struct RepoSettingsView: View {
5959 Button("Save") {
6060 Task { await model.setDescription(descriptionText) }
6161 }
62 .font(.caption)
62 .font(.gbSans(.caption))
6363 .disabled(model.working)
6464 .accessibilityIdentifier("settings-description-save")
6565 }
@@ -74,7 +74,7 @@ struct RepoSettingsView: View {
7474 Button("Save") {
7575 Task { await model.setWebsite(websiteText) }
7676 }
77 .font(.caption)
77 .font(.gbSans(.caption))
7878 .disabled(model.working)
7979 }
8080 }
@@ -97,10 +97,10 @@ struct RepoSettingsView: View {
9797 }
9898 .disabled(model.working)
9999 }
100 .font(.caption)
100 .font(.gbSans(.caption))
101101 .padding(.horizontal, 8)
102102 .padding(.vertical, 3)
103 .background(.quaternary, in: Capsule())
103 .background(.quaternary, in: gbChipShape)
104104 }
105105 }
106106 }
@@ -143,12 +143,12 @@ struct RepoSettingsView: View {
143143 ForEach(loaded.settings.branches, id: \.self) { branch in
144144 HStack {
145145 Label(branch, systemImage: "lock.shield")
146 .font(.subheadline)
146 .font(.gbSans(.subheadline))
147147 Spacer()
148148 Button("Unprotect") {
149149 Task { await model.unprotectBranch(branch) }
150150 }
151 .font(.caption)
151 .font(.gbSans(.caption))
152152 .disabled(model.working)
153153 }
154154 }
gitbay/Views/Repos/RepoView.swift +9 −9
@@ -18,8 +18,8 @@ struct RepoView: View {
1818 if let error = model.actionError {
1919 Section {
2020 Label(error, systemImage: "hand.raised")
21 .foregroundStyle(.orange)
22 .font(.subheadline)
21 .foregroundStyle(Color.gbWarn)
22 .font(.gbSans(.subheadline))
2323 }
2424 }
2525 header(detail)
@@ -123,28 +123,28 @@ struct RepoView: View {
123123 VStack(alignment: .leading, spacing: 8) {
124124 HStack(spacing: 6) {
125125 Text(detail.path)
126 .font(.headline)
126 .font(.gbSans(.headline))
127127 if detail.visibility == "private" {
128128 Image(systemName: "lock.fill")
129 .font(.caption)
129 .font(.gbSans(.caption))
130130 .foregroundStyle(.secondary)
131131 }
132132 if detail.isArchived {
133133 Text("archived")
134 .font(.caption2)
134 .font(.gbSans(.caption2))
135135 .padding(.horizontal, 5)
136136 .padding(.vertical, 1)
137 .background(.quaternary, in: Capsule())
137 .background(.quaternary, in: gbChipShape)
138138 }
139139 }
140140 if let description = detail.description, !description.isEmpty {
141141 Text(description)
142 .font(.subheadline)
142 .font(.gbSans(.subheadline))
143143 .foregroundStyle(.secondary)
144144 }
145145 if let website = detail.website, let url = URL(string: website) {
146146 Link(website, destination: url)
147 .font(.caption)
147 .font(.gbSans(.caption))
148148 .lineLimit(1)
149149 }
150150 HStack(spacing: 12) {
@@ -154,7 +154,7 @@ struct RepoView: View {
154154 .lineLimit(1)
155155 }
156156 }
157 .font(.caption)
157 .font(.gbSans(.caption))
158158 .foregroundStyle(.secondary)
159159 }
160160 .padding(.vertical, 4)
gitbay/Views/Repos/TreeView.swift +1 −1
@@ -57,7 +57,7 @@ private struct EntryRow: View {
5757 Spacer()
5858 if let size = entry.size, !entry.isDirectory {
5959 Text(size.formatted(.byteCount(style: .file)))
60 .font(.caption)
60 .font(.gbSans(.caption))
6161 .foregroundStyle(.tertiary)
6262 .monospacedDigit()
6363 }
gitbay/Views/Shared/ComposeSheet.swift +3 −3
@@ -27,15 +27,15 @@ struct ComposeSheet: View {
2727 Section("Body") {
2828 TextEditor(text: $bodyText)
2929 .frame(minHeight: 160)
30 .font(.body)
30 .font(.gbSans(.body))
3131 .autocorrectionDisabled()
3232 .accessibilityIdentifier("compose-body")
3333 }
3434 if let errorMessage {
3535 Section {
3636 Label(errorMessage, systemImage: "exclamationmark.triangle")
37 .foregroundStyle(.red)
38 .font(.subheadline)
37 .foregroundStyle(Color.gbBad)
38 .font(.gbSans(.subheadline))
3939 }
4040 }
4141 }
gitbay/Views/SignInView.swift +2 −2
@@ -44,7 +44,7 @@ struct SignInView: View {
4444 VStack(alignment: .leading, spacing: 8) {
4545 Text("Mint one over SSH on a machine that has your key:")
4646 Text(verbatim: "gitbay auth token create --name iphone --scope full --ttl 90d")
47 .font(.caption.monospaced())
47 .font(.gbMono(.caption))
4848 .textSelection(.enabled)
4949 Text("`--scope read` also works, but a read-only token cannot comment or merge.")
5050 }
@@ -53,7 +53,7 @@ struct SignInView: View {
5353 if let errorMessage {
5454 Section {
5555 Label(errorMessage, systemImage: "exclamationmark.triangle")
56 .foregroundStyle(.red)
56 .foregroundStyle(Color.gbBad)
5757 }
5858 }
5959
gitbay/gitbayApp.swift +11
@@ -5,10 +5,21 @@ struct gitbayApp: App {
55
66 @State private var session = SessionStore()
77
8 init() {
9 GitbayFonts.register()
10 }
11
812 var body: some Scene {
913 WindowGroup {
1014 ContentView()
1115 .environment(session)
16 .font(.gbSans(.body))
17 .tint(.gbAccent)
18 // The screenshot checkpoint forces appearance per run;
19 // simulator clones ignore the base device's setting.
20 .preferredColorScheme(
21 ProcessInfo.processInfo.arguments.contains("-gb-dark") ? .dark : nil
22 )
1223 }
1324 }
1425 }
gitbayTests/DashboardViewModelTests.swift +12
@@ -14,11 +14,17 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
1414
1515 private let dashboardJSON = """
1616 {"protocol_version":1,"data":{\
17 "review_queue":[{"repo":"krz/gitbay-ios","number":3,"title":"adopt aggregate","author":"cmc",\
18 "state":"open","updated_at":"2026-08-27T10:00:00.000Z"}],\
1719 "pinned":[{"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"}],\
1820 "open_mrs":[{"repo":"krz/gitbay-ios","number":3,"title":"adopt aggregate","author":"cmc",\
1921 "state":"open","updated_at":"2026-08-27T10:00:00.000Z"}],\
2022 "assigned_issues":[{"repo":"krz/gitbay","number":11,"title":"iOS app","author":"krz",\
2123 "state":"open","updated_at":"2026-08-26T10:00:00.000Z"}],\
24 "open_issues":[{"repo":"krz/gitbay","number":11,"title":"iOS app","author":"krz",\
25 "state":"open","updated_at":"2026-08-26T10:00:00.000Z"}],\
26 "recent_activity":[{"id":7,"repo":"krz/gitbay-ios","actor":"cmc","kind":"mr.merged",\
27 "data":{"number":2},"created_at":"2026-08-27T10:30:00.000Z"}],\
2228 "builds":[{"repo":"krz/gitbay","number":9,"job":"ci","status":"success",\
2329 "sha":"65ba14e0000000000000","ref":"refs/heads/main",\
2430 "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}]},\
@@ -42,8 +48,11 @@ struct DashboardViewModelTests {
4248
4349 let data = try #require(model.state.value)
4450 #expect(data.pinned.map(\.path) == ["krz/gitbay"])
51 #expect(data.reviewQueue.map(\.id) == ["krz/gitbay-ios#3"])
4552 #expect(data.openMRs.map(\.id) == ["krz/gitbay-ios#3"])
4653 #expect(data.assignedIssues.first?.number == 11)
54 #expect(data.openIssues.first?.number == 11)
55 #expect(data.recentActivity.first?.phrase == "merged !2")
4756 #expect(data.builds.first?.status == "success")
4857 // The whole screen cost exactly one request.
4958 #expect(stub.seen.count == 1)
@@ -60,6 +69,9 @@ struct DashboardViewModelTests {
6069 let data = try #require(model.state.value)
6170 #expect(data.pinned.isEmpty)
6271 #expect(data.openMRs.isEmpty)
72 #expect(data.reviewQueue.isEmpty)
73 #expect(data.openIssues.isEmpty)
74 #expect(data.recentActivity.isEmpty)
6375 }
6476
6577 @Test func aFailureIsTheScreensState() async throws {
gitbayTests/DiscoveryTests.swift +3 −2
@@ -30,9 +30,10 @@ struct FeedEventTests {
3030
3131 let build = try decode("""
3232 {"id":1,"repo":"krz/gitbay","actor":"","kind":"build.success",\
33 "data":{"number":56},"created_at":"2026-08-27T15:31:10Z"}
33 "data":{"number":56,"job":"ci"},"created_at":"2026-08-27T15:31:10Z"}
3434 """)
35 #expect(build.phrase == "build #56 succeeded")
35 #expect(build.phrase == "build success ci")
36 #expect(build.displayActor == "gitbay")
3637 #expect(build.destination == .build(repo: "krz/gitbay", number: 56))
3738
3839 let release = try decode("""
gitbayUITests/LiveSmokeUITests.swift +60
@@ -23,6 +23,11 @@ final class LiveSmokeUITests: XCTestCase {
2323 )
2424 continueAfterFailure = false
2525 app = XCUIApplication()
26 // xcodebuild runs tests on simulator clones, so the base
27 // device's appearance never applies; force it per run.
28 if ProcessInfo.processInfo.environment["GITBAY_UITEST_DARK"] == "1" {
29 app.launchArguments.append("-gb-dark")
30 }
2631 app.launch()
2732 }
2833
@@ -473,3 +478,58 @@ extension LiveSmokeUITests {
473478 XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted")
474479 }
475480 }
481
482extension LiveSmokeUITests {
483
484 /// The pre-merge screenshot checkpoint: walks the dense screens and
485 /// attaches captures. Run per device/appearance; export the
486 /// attachments from the xcresult. Signs in first when the device has
487 /// no session and TEST_RUNNER_GITBAY_UITEST_TOKEN is provided.
488 func testScreenshotCheckpoint() throws {
489 signInIfNeeded()
490
491 snap("dashboard")
492
493 openRepo("krz/gitbay")
494 snap("repo")
495
496 app.staticTexts["Merge Requests"].firstMatch.tap()
497 let all = app.segmentedControls.buttons["All"].firstMatch
498 XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
499 all.tap()
500 // A real MR row carries its !number; the picker row does not.
501 let mrNumber = app.staticTexts
502 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
503 XCTAssertTrue(mrNumber.waitForExistence(timeout: 15), "no MR rows after All")
504 snap("mr-list")
505
506 mrNumber.tap()
507 XCTAssertTrue(app.staticTexts["Diff"].firstMatch.waitForExistence(timeout: 15),
508 "MR detail did not open")
509 snap("mr-detail")
510
511 app.staticTexts["Diff"].firstMatch.tap()
512 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
513 snap("diff")
514 }
515
516 private func signInIfNeeded() {
517 let tokenField = app.secureTextFields.firstMatch
518 guard tokenField.waitForExistence(timeout: 5) else { return }
519 guard let token = ProcessInfo.processInfo.environment["GITBAY_UITEST_TOKEN"] else {
520 XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
521 return
522 }
523 tokenField.tap()
524 tokenField.typeText(token + "\n")
525 XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
526 .waitForExistence(timeout: 20), "sign-in did not land")
527 }
528
529 private func snap(_ name: String) {
530 let attachment = XCTAttachment(screenshot: app.screenshot())
531 attachment.name = name
532 attachment.lifetime = .keepAlways
533 add(attachment)
534 }
535}