design: align native app with web !12
43 files changed, +603 −359
gitbay/Assets.xcassets/AccentColor.colorset/Contents.json +33 −6
| @@ -1,11 +1,38 @@ | ||
| 1 | 1 | { |
| 2 | "colors" : [ | |
| 2 | "colors": [ | |
| 3 | 3 | { |
| 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" | |
| 5 | 32 | } |
| 6 | 33 | ], |
| 7 | "info" : { | |
| 8 | "author" : "xcode", | |
| 9 | "version" : 1 | |
| 34 | "info": { | |
| 35 | "author": "xcode", | |
| 36 | "version": 1 | |
| 10 | 37 | } |
| 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 @@ | ||
| 1 | 1 | { |
| 2 | "images" : [ | |
| 2 | "images": [ | |
| 3 | 3 | { |
| 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" | |
| 29 | 8 | } |
| 30 | 9 | ], |
| 31 | "info" : { | |
| 32 | "author" : "xcode", | |
| 33 | "version" : 1 | |
| 10 | "info": { | |
| 11 | "author": "xcode", | |
| 12 | "version": 1 | |
| 34 | 13 | } |
| 35 | } | |
| 14 | } | |
| \ No newline at end of file | ||
gitbay/Dashboard/DashboardModels.swift +24 −4
| @@ -1,18 +1,38 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 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. | |
| 5 | 6 | nonisolated struct DashboardData: Decodable, Sendable, Hashable { |
| 7 | let reviewQueue: [DashboardItem] | |
| 8 | let assignedIssues: [DashboardItem] | |
| 9 | let openMRs: [DashboardItem] | |
| 10 | let openIssues: [DashboardItem] | |
| 6 | 11 | /// Pinned repos share `repo list`'s row shape. |
| 7 | 12 | 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. | |
| 10 | 16 | let builds: [DashboardBuild] |
| 11 | 17 | |
| 12 | 18 | enum CodingKeys: String, CodingKey { |
| 13 | 19 | case pinned, builds |
| 20 | case reviewQueue = "review_queue" | |
| 14 | 21 | case openMRs = "open_mrs" |
| 15 | 22 | 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) ?? [] | |
| 16 | 36 | } |
| 17 | 37 | } |
| 18 | 38 | |
gitbay/Dashboard/DashboardViewModel.swift +2 −2
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import Observation |
| 3 | 3 | |
| 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 | |
| 6 | 6 | /// drained the rate bucket at 64 of 66 repos; krz/gitbay#41 added the |
| 7 | 7 | /// aggregate. |
| 8 | 8 | @Observable |
gitbay/Discovery/FeedEvent.swift +24 −9
| @@ -20,33 +20,48 @@ nonisolated struct FeedEvent: Decodable, Sendable, Hashable, Identifiable { | ||
| 20 | 20 | let sha: String? |
| 21 | 21 | let tag: String? |
| 22 | 22 | let title: String? |
| 23 | let job: String? | |
| 23 | 24 | } |
| 24 | 25 | |
| 25 | /// "opened !4", "build #56 succeeded" — the verb phrase after the actor. | |
| 26 | /// The same verb and reference phrasing as the web dashboard. | |
| 26 | 27 | var phrase: String { |
| 27 | 28 | let n = data?.number.map(String.init) ?? "" |
| 28 | 29 | switch kind { |
| 29 | case "mr.created": return "opened !\(n)" | |
| 30 | case "mr.created": return "opened merge request !\(n)" | |
| 30 | 31 | 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)" | |
| 35 | 37 | 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 ?? "")" | |
| 38 | 40 | case "release.created": return "released \(data?.tag ?? "")" |
| 39 | 41 | 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))" | |
| 40 | 50 | default: return kind |
| 41 | 51 | } |
| 42 | 52 | } |
| 43 | 53 | |
| 54 | var displayActor: String { | |
| 55 | guard let actor, !actor.isEmpty else { return "gitbay" } | |
| 56 | return actor | |
| 57 | } | |
| 58 | ||
| 44 | 59 | var icon: (name: String, isPositive: Bool?) { |
| 45 | 60 | switch kind { |
| 46 | 61 | case "mr.created": ("arrow.triangle.merge", nil) |
| 47 | 62 | case "mr.merged": ("arrow.triangle.merge", true) |
| 48 | 63 | 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) | |
| 50 | 65 | case "issue.closed": ("checkmark.circle", true) |
| 51 | 66 | case "issue.commented": ("bubble.left", nil) |
| 52 | 67 | 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 @@ | ||
| 1 | Atkinson Hyperlegible Next and Atkinson Hyperlegible Mono | |
| 2 | Copyright 2020-2025 Braille Institute of America, Inc. | |
| 3 | Licensed under the SIL Open Font License, Version 1.1. | |
| 4 | https://openfontlicense.org | |
| 5 | Bundled unmodified apart from static instancing of the variable weight | |
| 6 | axis, matching the faces gitbay's web UI serves. | |
gitbay/Theme/Theme.swift added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | import SwiftUI | |
| 2 | import UIKit | |
| 3 | import 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. | |
| 8 | extension 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 | ||
| 37 | extension 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. | |
| 49 | nonisolated 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. | |
| 54 | extension 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. | |
| 85 | enum 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. | |
| 101 | struct 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 { | ||
| 25 | 25 | NavigationLink(value: OrgRoute.org(membership.org)) { |
| 26 | 26 | HStack { |
| 27 | 27 | Label(membership.org, systemImage: "building.2") |
| 28 | .font(.subheadline) | |
| 28 | .font(.gbSans(.subheadline)) | |
| 29 | 29 | Spacer() |
| 30 | 30 | Text(membership.role) |
| 31 | .font(.caption) | |
| 31 | .font(.gbSans(.caption)) | |
| 32 | 32 | .padding(.horizontal, 6) |
| 33 | 33 | .padding(.vertical, 2) |
| 34 | .background(.quaternary, in: Capsule()) | |
| 34 | .background(.quaternary, in: gbChipShape) | |
| 35 | 35 | } |
| 36 | 36 | } |
| 37 | 37 | } |
| @@ -45,15 +45,15 @@ struct AccountView: View { | ||
| 45 | 45 | if let error = model.actionError { |
| 46 | 46 | Section { |
| 47 | 47 | Label(error, systemImage: "hand.raised") |
| 48 | .foregroundStyle(.orange) | |
| 49 | .font(.subheadline) | |
| 48 | .foregroundStyle(Color.gbWarn) | |
| 49 | .font(.gbSans(.subheadline)) | |
| 50 | 50 | } |
| 51 | 51 | } |
| 52 | 52 | if let notice = model.notice { |
| 53 | 53 | Section { |
| 54 | 54 | Label(notice, systemImage: "envelope") |
| 55 | 55 | .foregroundStyle(.secondary) |
| 56 | .font(.subheadline) | |
| 56 | .font(.gbSans(.subheadline)) | |
| 57 | 57 | } |
| 58 | 58 | } |
| 59 | 59 | emailSection |
| @@ -131,7 +131,7 @@ struct AccountView: View { | ||
| 131 | 131 | ForEach(keys) { key in |
| 132 | 132 | VStack(alignment: .leading, spacing: 2) { |
| 133 | 133 | Text(key.fingerprint) |
| 134 | .font(.caption.monospaced()) | |
| 134 | .font(.gbMono(.caption)) | |
| 135 | 135 | .lineLimit(1) |
| 136 | 136 | .truncationMode(.middle) |
| 137 | 137 | HStack(spacing: 6) { |
| @@ -139,9 +139,9 @@ struct AccountView: View { | ||
| 139 | 139 | Text(key.scope) |
| 140 | 140 | .padding(.horizontal, 5) |
| 141 | 141 | .padding(.vertical, 1) |
| 142 | .background(.quaternary, in: Capsule()) | |
| 142 | .background(.quaternary, in: gbChipShape) | |
| 143 | 143 | } |
| 144 | .font(.caption2) | |
| 144 | .font(.gbSans(.caption2)) | |
| 145 | 145 | .foregroundStyle(.secondary) |
| 146 | 146 | } |
| 147 | 147 | .swipeActions { |
| @@ -154,7 +154,7 @@ struct AccountView: View { | ||
| 154 | 154 | addingSSH = true |
| 155 | 155 | } label: { |
| 156 | 156 | Label("Add SSH Key", systemImage: "plus") |
| 157 | .font(.subheadline) | |
| 157 | .font(.gbSans(.subheadline)) | |
| 158 | 158 | } |
| 159 | 159 | .accessibilityIdentifier("add-ssh-key") |
| 160 | 160 | } header: { |
| @@ -169,12 +169,12 @@ struct AccountView: View { | ||
| 169 | 169 | ForEach(keys) { key in |
| 170 | 170 | VStack(alignment: .leading, spacing: 2) { |
| 171 | 171 | Text(key.fingerprint) |
| 172 | .font(.caption.monospaced()) | |
| 172 | .font(.gbMono(.caption)) | |
| 173 | 173 | .lineLimit(1) |
| 174 | 174 | .truncationMode(.middle) |
| 175 | 175 | if !key.emailList.isEmpty { |
| 176 | 176 | Text(key.emailList.joined(separator: ", ")) |
| 177 | .font(.caption2) | |
| 177 | .font(.gbSans(.caption2)) | |
| 178 | 178 | .foregroundStyle(.secondary) |
| 179 | 179 | } |
| 180 | 180 | } |
| @@ -188,7 +188,7 @@ struct AccountView: View { | ||
| 188 | 188 | addingPGP = true |
| 189 | 189 | } label: { |
| 190 | 190 | Label("Add PGP Key", systemImage: "plus") |
| 191 | .font(.subheadline) | |
| 191 | .font(.gbSans(.subheadline)) | |
| 192 | 192 | } |
| 193 | 193 | .accessibilityIdentifier("add-pgp-key") |
| 194 | 194 | } header: { |
| @@ -211,7 +211,7 @@ struct AccountView: View { | ||
| 211 | 211 | emailAddress = "" |
| 212 | 212 | Task { await model.addEmail(address) } |
| 213 | 213 | } |
| 214 | .font(.caption) | |
| 214 | .font(.gbSans(.caption)) | |
| 215 | 215 | .disabled(!emailAddress.contains("@") || model.working) |
| 216 | 216 | } |
| 217 | 217 | HStack { |
| @@ -224,7 +224,7 @@ struct AccountView: View { | ||
| 224 | 224 | verifyCode = "" |
| 225 | 225 | Task { await model.verifyEmail(code: code) } |
| 226 | 226 | } |
| 227 | .font(.caption) | |
| 227 | .font(.gbSans(.caption)) | |
| 228 | 228 | .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working) |
| 229 | 229 | .accessibilityIdentifier("email-verify") |
| 230 | 230 | } |
| @@ -256,7 +256,7 @@ private struct KeyPasteSheet: View { | ||
| 256 | 256 | Section { |
| 257 | 257 | TextEditor(text: $text) |
| 258 | 258 | .frame(minHeight: 120) |
| 259 | .font(.caption.monospaced()) | |
| 259 | .font(.gbMono(.caption)) | |
| 260 | 260 | .autocorrectionDisabled() |
| 261 | 261 | .textInputAutocapitalization(.never) |
| 262 | 262 | .accessibilityIdentifier("key-paste-text") |
| @@ -274,8 +274,8 @@ private struct KeyPasteSheet: View { | ||
| 274 | 274 | if let errorMessage { |
| 275 | 275 | Section { |
| 276 | 276 | Label(errorMessage, systemImage: "exclamationmark.triangle") |
| 277 | .foregroundStyle(.red) | |
| 278 | .font(.subheadline) | |
| 277 | .foregroundStyle(Color.gbBad) | |
| 278 | .font(.gbSans(.subheadline)) | |
| 279 | 279 | } |
| 280 | 280 | } |
| 281 | 281 | } |
gitbay/Views/Builds/BuildListView.swift +11 −11
| @@ -15,8 +15,8 @@ struct BuildListView: View { | ||
| 15 | 15 | if let error = model.actionError { |
| 16 | 16 | Section { |
| 17 | 17 | Label(error, systemImage: "hand.raised") |
| 18 | .foregroundStyle(.orange) | |
| 19 | .font(.subheadline) | |
| 18 | .foregroundStyle(Color.gbWarn) | |
| 19 | .font(.gbSans(.subheadline)) | |
| 20 | 20 | } |
| 21 | 21 | } |
| 22 | 22 | ForEach(model.state.value ?? []) { build in |
| @@ -65,23 +65,23 @@ private struct BuildRow: View { | ||
| 65 | 65 | .foregroundStyle(color) |
| 66 | 66 | VStack(alignment: .leading, spacing: 2) { |
| 67 | 67 | Text("#\(build.number) \(build.job)") |
| 68 | .font(.subheadline.weight(.medium)) | |
| 68 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 69 | 69 | HStack(spacing: 6) { |
| 70 | 70 | Text(build.ref) |
| 71 | 71 | Text(build.shortSHA) |
| 72 | .font(.caption.monospaced()) | |
| 72 | .font(.gbMono(.caption)) | |
| 73 | 73 | } |
| 74 | .font(.caption) | |
| 74 | .font(.gbSans(.caption)) | |
| 75 | 75 | .foregroundStyle(.secondary) |
| 76 | 76 | } |
| 77 | 77 | Spacer() |
| 78 | 78 | VStack(alignment: .trailing, spacing: 2) { |
| 79 | 79 | Text(build.status) |
| 80 | .font(.caption.weight(.medium)) | |
| 80 | .font(.gbSans(.caption).weight(.medium)) | |
| 81 | 81 | .foregroundStyle(color) |
| 82 | 82 | Text(build.createdAt, format: .relative(presentation: .named)) |
| 83 | .font(.caption) | |
| 84 | .foregroundStyle(.tertiary) | |
| 83 | .font(.gbSans(.caption)) | |
| 84 | .foregroundStyle(.secondary) | |
| 85 | 85 | } |
| 86 | 86 | } |
| 87 | 87 | .padding(.vertical, 2) |
| @@ -99,9 +99,9 @@ private struct BuildRow: View { | ||
| 99 | 99 | |
| 100 | 100 | private var color: Color { |
| 101 | 101 | 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 | |
| 105 | 105 | default: .secondary |
| 106 | 106 | } |
| 107 | 107 | } |
gitbay/Views/Builds/BuildLogView.swift +1 −1
| @@ -25,7 +25,7 @@ struct BuildLogView: View { | ||
| 25 | 25 | } else { |
| 26 | 26 | ScrollView([.horizontal, .vertical]) { |
| 27 | 27 | Text(log) |
| 28 | .font(.caption2.monospaced()) | |
| 28 | .font(.gbMono(.caption2)) | |
| 29 | 29 | .frame(maxWidth: .infinity, alignment: .leading) |
| 30 | 30 | .padding(12) |
| 31 | 31 | .textSelection(.enabled) |
gitbay/Views/Dashboard/DashboardView.swift +83 −93
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | /// What needs me, in one read. | |
| 3 | /// The native representation of `gitbay dashboard`, ordered and phrased | |
| 4 | /// like the logged-in web dashboard. | |
| 4 | 5 | struct DashboardView: View { |
| 5 | 6 | |
| 6 | 7 | @State private var model: DashboardViewModel |
| @@ -12,20 +13,28 @@ struct DashboardView: View { | ||
| 12 | 13 | var body: some View { |
| 13 | 14 | List { |
| 14 | 15 | if let data = model.state.value { |
| 15 | if !data.pinned.isEmpty { | |
| 16 | pinnedSection(data.pinned) | |
| 17 | } | |
| 18 | 16 | 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: "!" | |
| 22 | 29 | ) { MRRoute.mr(repo: $0.repo, number: $0.number) } |
| 30 | ||
| 23 | 31 | 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: "#" | |
| 27 | 34 | ) { IssueRoute.issue(repo: $0.repo, number: $0.number) } |
| 28 | buildsSection(data.builds) | |
| 35 | ||
| 36 | pinnedSection(data.pinned) | |
| 37 | activitySection(data.recentActivity) | |
| 29 | 38 | } |
| 30 | 39 | } |
| 31 | 40 | .overlay { LoadStateOverlay(state: model.state) } |
| @@ -43,33 +52,6 @@ struct DashboardView: View { | ||
| 43 | 52 | .refreshable { await model.load() } |
| 44 | 53 | } |
| 45 | 54 | |
| 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 | ||
| 73 | 55 | private func itemSection( |
| 74 | 56 | _ title: String, |
| 75 | 57 | items: [DashboardItem], |
| @@ -77,86 +59,94 @@ struct DashboardView: View { | ||
| 77 | 59 | marker: String, |
| 78 | 60 | route: @escaping (DashboardItem) -> some Hashable |
| 79 | 61 | ) -> some View { |
| 80 | Section(title) { | |
| 62 | Section { | |
| 81 | 63 | if items.isEmpty { |
| 82 | 64 | Text(empty) |
| 83 | .font(.subheadline) | |
| 65 | .font(.gbSans(.subheadline)) | |
| 84 | 66 | .foregroundStyle(.secondary) |
| 85 | 67 | } else { |
| 86 | 68 | ForEach(items) { item in |
| 87 | 69 | 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) | |
| 108 | 71 | } |
| 109 | 72 | } |
| 110 | 73 | } |
| 74 | } header: { | |
| 75 | sectionHeader(title, count: items.count) | |
| 111 | 76 | } |
| 112 | 77 | } |
| 113 | 78 | |
| 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)) | |
| 119 | 109 | .foregroundStyle(.secondary) |
| 120 | 110 | } 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) | |
| 132 | 119 | } |
| 133 | Spacer() | |
| 134 | Text(build.createdAt, format: .relative(presentation: .named)) | |
| 135 | .font(.caption) | |
| 136 | .foregroundStyle(.tertiary) | |
| 137 | 120 | } |
| 138 | 121 | } |
| 139 | 122 | } |
| 140 | 123 | } |
| 124 | } header: { | |
| 125 | Text("Pinned") | |
| 141 | 126 | } |
| 142 | 127 | } |
| 143 | 128 | |
| 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") | |
| 151 | 142 | } |
| 152 | 143 | } |
| 153 | 144 | |
| 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) | |
| 160 | 150 | } |
| 161 | 151 | } |
| 162 | 152 | } |
gitbay/Views/Discovery/FeedView.swift +18 −13
| @@ -16,7 +16,7 @@ struct FeedView: View { | ||
| 16 | 16 | var body: some View { |
| 17 | 17 | List { |
| 18 | 18 | ForEach(list.state.value ?? []) { event in |
| 19 | link(for: event) | |
| 19 | FeedLink(event: event) | |
| 20 | 20 | } |
| 21 | 21 | PageFooter(list: list) |
| 22 | 22 | } |
| @@ -31,8 +31,15 @@ struct FeedView: View { | ||
| 31 | 31 | .refreshable { await list.reload() } |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | } | |
| 35 | ||
| 36 | /// One feed event linked to the same native destination everywhere it is | |
| 37 | /// represented, including the dashboard's recent-activity section. | |
| 38 | struct FeedLink: View { | |
| 39 | let event: FeedEvent | |
| 40 | ||
| 34 | 41 | @ViewBuilder |
| 35 | private func link(for event: FeedEvent) -> some View { | |
| 42 | var body: some View { | |
| 36 | 43 | switch event.destination { |
| 37 | 44 | case .repo(let repo): |
| 38 | 45 | NavigationLink(value: RepoRoute.repo(repo)) { FeedRow(event: event) } |
| @@ -50,7 +57,7 @@ struct FeedView: View { | ||
| 50 | 57 | } |
| 51 | 58 | } |
| 52 | 59 | |
| 53 | private struct FeedRow: View { | |
| 60 | struct FeedRow: View { | |
| 54 | 61 | let event: FeedEvent |
| 55 | 62 | |
| 56 | 63 | var body: some View { |
| @@ -60,30 +67,28 @@ private struct FeedRow: View { | ||
| 60 | 67 | .frame(width: 22) |
| 61 | 68 | VStack(alignment: .leading, spacing: 2) { |
| 62 | 69 | Text(event.repo) |
| 63 | .font(.caption) | |
| 70 | .font(.gbSans(.caption)) | |
| 64 | 71 | .foregroundStyle(.secondary) |
| 65 | 72 | 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)) | |
| 70 | 75 | Text(event.phrase) |
| 71 | .font(.subheadline) | |
| 76 | .font(.gbSans(.subheadline)) | |
| 72 | 77 | .lineLimit(1) |
| 73 | 78 | } |
| 74 | 79 | } |
| 75 | 80 | Spacer() |
| 76 | 81 | Text(event.createdAt, format: .relative(presentation: .named)) |
| 77 | .font(.caption) | |
| 78 | .foregroundStyle(.tertiary) | |
| 82 | .font(.gbSans(.caption)) | |
| 83 | .foregroundStyle(.secondary) | |
| 79 | 84 | } |
| 80 | 85 | .padding(.vertical, 2) |
| 81 | 86 | } |
| 82 | 87 | |
| 83 | 88 | private var color: Color { |
| 84 | 89 | switch event.icon.isPositive { |
| 85 | case true: .green | |
| 86 | case false: .red | |
| 90 | case true: .gbOK | |
| 91 | case false: .gbBad | |
| 87 | 92 | default: .secondary |
| 88 | 93 | } |
| 89 | 94 | } |
gitbay/Views/Discovery/GrepView.swift +3 −3
| @@ -20,18 +20,18 @@ struct GrepView: View { | ||
| 20 | 20 | )) { |
| 21 | 21 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 22 | 22 | Text(String(match.line)) |
| 23 | .font(.caption.monospaced()) | |
| 23 | .font(.gbMono(.caption)) | |
| 24 | 24 | .foregroundStyle(.tertiary) |
| 25 | 25 | .frame(minWidth: 32, alignment: .trailing) |
| 26 | 26 | Text(match.text.trimmingCharacters(in: .whitespaces)) |
| 27 | .font(.caption.monospaced()) | |
| 27 | .font(.gbMono(.caption)) | |
| 28 | 28 | .lineLimit(2) |
| 29 | 29 | } |
| 30 | 30 | } |
| 31 | 31 | } |
| 32 | 32 | } header: { |
| 33 | 33 | Text(group.file) |
| 34 | .font(.caption.monospaced()) | |
| 34 | .font(.gbMono(.caption)) | |
| 35 | 35 | .textCase(nil) |
| 36 | 36 | } |
| 37 | 37 | } |
gitbay/Views/Discovery/ProfileView.swift +8 −8
| @@ -17,24 +17,24 @@ struct ProfileView: View { | ||
| 17 | 17 | HStack(spacing: 8) { |
| 18 | 18 | Image(systemName: profile.kind == "org" |
| 19 | 19 | ? "building.2" : "person.crop.circle") |
| 20 | .font(.title2) | |
| 20 | .font(.gbSans(.title2)) | |
| 21 | 21 | .foregroundStyle(.secondary) |
| 22 | 22 | Text(profile.name) |
| 23 | .font(.title3.weight(.semibold)) | |
| 23 | .font(.gbSans(.title3).weight(.semibold)) | |
| 24 | 24 | Text(profile.kind) |
| 25 | .font(.caption2) | |
| 25 | .font(.gbSans(.caption2)) | |
| 26 | 26 | .padding(.horizontal, 6) |
| 27 | 27 | .padding(.vertical, 1) |
| 28 | .background(.quaternary, in: Capsule()) | |
| 28 | .background(.quaternary, in: gbChipShape) | |
| 29 | 29 | } |
| 30 | 30 | if let description = profile.description, !description.isEmpty { |
| 31 | 31 | Text(description) |
| 32 | .font(.subheadline) | |
| 32 | .font(.gbSans(.subheadline)) | |
| 33 | 33 | .foregroundStyle(.secondary) |
| 34 | 34 | } |
| 35 | 35 | if let website = profile.website, let url = URL(string: website) { |
| 36 | 36 | Link(website, destination: url) |
| 37 | .font(.caption) | |
| 37 | .font(.gbSans(.caption)) | |
| 38 | 38 | .lineLimit(1) |
| 39 | 39 | } |
| 40 | 40 | } |
| @@ -55,10 +55,10 @@ struct ProfileView: View { | ||
| 55 | 55 | NavigationLink(value: RepoRoute.repo(repo.path)) { |
| 56 | 56 | VStack(alignment: .leading, spacing: 2) { |
| 57 | 57 | Text(repo.name) |
| 58 | .font(.subheadline.weight(.medium)) | |
| 58 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 59 | 59 | if let description = repo.description, !description.isEmpty { |
| 60 | 60 | Text(description) |
| 61 | .font(.caption) | |
| 61 | .font(.gbSans(.caption)) | |
| 62 | 62 | .foregroundStyle(.secondary) |
| 63 | 63 | .lineLimit(1) |
| 64 | 64 | } |
gitbay/Views/Issues/IssueListView.swift +9 −9
| @@ -75,33 +75,33 @@ private struct IssueRow: View { | ||
| 75 | 75 | VStack(alignment: .leading, spacing: 4) { |
| 76 | 76 | HStack(alignment: .firstTextBaseline, spacing: 6) { |
| 77 | 77 | Text("#\(issue.number)") |
| 78 | .font(.caption.monospaced()) | |
| 78 | .font(.gbMono(.caption)) | |
| 79 | 79 | .foregroundStyle(.secondary) |
| 80 | 80 | Text(issue.title) |
| 81 | .font(.subheadline.weight(.medium)) | |
| 81 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 82 | 82 | .lineLimit(2) |
| 83 | 83 | } |
| 84 | 84 | HStack(spacing: 6) { |
| 85 | 85 | 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) | |
| 88 | 88 | ForEach(issue.labels ?? [], id: \.self) { label in |
| 89 | 89 | Text(label) |
| 90 | .font(.caption2) | |
| 90 | .font(.gbSans(.caption2)) | |
| 91 | 91 | .padding(.horizontal, 5) |
| 92 | 92 | .padding(.vertical, 1) |
| 93 | .background(.quaternary, in: Capsule()) | |
| 93 | .background(.quaternary, in: gbChipShape) | |
| 94 | 94 | } |
| 95 | 95 | Spacer() |
| 96 | 96 | if let assignees = issue.assignees, !assignees.isEmpty { |
| 97 | 97 | Text(assignees.joined(separator: ", ")) |
| 98 | .font(.caption) | |
| 98 | .font(.gbSans(.caption)) | |
| 99 | 99 | .foregroundStyle(.secondary) |
| 100 | 100 | .lineLimit(1) |
| 101 | 101 | } |
| 102 | 102 | Text(issue.createdAt, format: .relative(presentation: .named)) |
| 103 | .font(.caption) | |
| 104 | .foregroundStyle(.tertiary) | |
| 103 | .font(.gbSans(.caption)) | |
| 104 | .foregroundStyle(.secondary) | |
| 105 | 105 | } |
| 106 | 106 | } |
| 107 | 107 | .padding(.vertical, 2) |
gitbay/Views/Issues/IssueView.swift +13 −13
| @@ -24,8 +24,8 @@ struct IssueView: View { | ||
| 24 | 24 | if let error = model.actionError { |
| 25 | 25 | Section { |
| 26 | 26 | Label(error, systemImage: "hand.raised") |
| 27 | .foregroundStyle(.orange) | |
| 28 | .font(.subheadline) | |
| 27 | .foregroundStyle(Color.gbWarn) | |
| 28 | .font(.gbSans(.subheadline)) | |
| 29 | 29 | } |
| 30 | 30 | } |
| 31 | 31 | |
| @@ -68,11 +68,11 @@ struct IssueView: View { | ||
| 68 | 68 | Section { |
| 69 | 69 | VStack(alignment: .leading, spacing: 6) { |
| 70 | 70 | Text(issue.title) |
| 71 | .font(.headline) | |
| 71 | .font(.gbSans(.headline)) | |
| 72 | 72 | HStack(spacing: 6) { |
| 73 | 73 | 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) | |
| 76 | 76 | Text("by \(issue.author)") |
| 77 | 77 | Text(issue.createdAt, format: .relative(presentation: .named)) |
| 78 | 78 | .foregroundStyle(.tertiary) |
| @@ -80,7 +80,7 @@ struct IssueView: View { | ||
| 80 | 80 | Label(milestone, systemImage: "flag") |
| 81 | 81 | } |
| 82 | 82 | } |
| 83 | .font(.caption) | |
| 83 | .font(.gbSans(.caption)) | |
| 84 | 84 | .foregroundStyle(.secondary) |
| 85 | 85 | } |
| 86 | 86 | .padding(.vertical, 2) |
| @@ -138,10 +138,10 @@ struct IssueView: View { | ||
| 138 | 138 | } label: { |
| 139 | 139 | HStack { |
| 140 | 140 | Label(issue.milestone ?? "None", systemImage: "flag") |
| 141 | .font(.subheadline) | |
| 141 | .font(.gbSans(.subheadline)) | |
| 142 | 142 | Spacer() |
| 143 | 143 | Image(systemName: "chevron.up.chevron.down") |
| 144 | .font(.caption2) | |
| 144 | .font(.gbSans(.caption2)) | |
| 145 | 145 | .foregroundStyle(.secondary) |
| 146 | 146 | } |
| 147 | 147 | } |
| @@ -167,10 +167,10 @@ struct IssueView: View { | ||
| 167 | 167 | } |
| 168 | 168 | .disabled(model.working) |
| 169 | 169 | } |
| 170 | .font(.caption) | |
| 170 | .font(.gbSans(.caption)) | |
| 171 | 171 | .padding(.horizontal, 8) |
| 172 | 172 | .padding(.vertical, 3) |
| 173 | .background(.quaternary, in: Capsule()) | |
| 173 | .background(.quaternary, in: gbChipShape) | |
| 174 | 174 | } |
| 175 | 175 | } |
| 176 | 176 | } |
| @@ -183,13 +183,13 @@ struct IssueView: View { | ||
| 183 | 183 | VStack(alignment: .leading, spacing: 4) { |
| 184 | 184 | HStack { |
| 185 | 185 | Text(comment.author) |
| 186 | .font(.caption.weight(.semibold)) | |
| 186 | .font(.gbSans(.caption).weight(.semibold)) | |
| 187 | 187 | Text(comment.createdAt, format: .relative(presentation: .named)) |
| 188 | .font(.caption) | |
| 188 | .font(.gbSans(.caption)) | |
| 189 | 189 | .foregroundStyle(.tertiary) |
| 190 | 190 | } |
| 191 | 191 | MarkdownView(markdown: comment.body) |
| 192 | .font(.subheadline) | |
| 192 | .font(.gbSans(.subheadline)) | |
| 193 | 193 | } |
| 194 | 194 | .padding(.vertical, 2) |
| 195 | 195 | } |
gitbay/Views/MRs/DiffView.swift +16 −14
| @@ -55,7 +55,7 @@ private struct FileDiffSection: View { | ||
| 55 | 55 | if !collapsed { |
| 56 | 56 | if file.isBinary { |
| 57 | 57 | Text("Binary file") |
| 58 | .font(.caption) | |
| 58 | .font(.gbSans(.caption)) | |
| 59 | 59 | .foregroundStyle(.secondary) |
| 60 | 60 | } else { |
| 61 | 61 | ForEach(file.hunks) { hunk in |
| @@ -69,20 +69,20 @@ private struct FileDiffSection: View { | ||
| 69 | 69 | } label: { |
| 70 | 70 | HStack(spacing: 6) { |
| 71 | 71 | Image(systemName: collapsed ? "chevron.right" : "chevron.down") |
| 72 | .font(.caption2) | |
| 72 | .font(.gbSans(.caption2)) | |
| 73 | 73 | Text(file.displayPath) |
| 74 | .font(.caption.monospaced().weight(.semibold)) | |
| 74 | .font(.gbMono(.caption).weight(.semibold)) | |
| 75 | 75 | .lineLimit(1) |
| 76 | 76 | .truncationMode(.head) |
| 77 | 77 | if file.isNew { |
| 78 | Text("new").font(.caption2).foregroundStyle(.green) | |
| 78 | Text("new").font(.gbSans(.caption2)).foregroundStyle(Color.gbOK) | |
| 79 | 79 | } |
| 80 | 80 | if file.isDeleted { |
| 81 | Text("deleted").font(.caption2).foregroundStyle(.red) | |
| 81 | Text("deleted").font(.gbSans(.caption2)).foregroundStyle(Color.gbBad) | |
| 82 | 82 | } |
| 83 | 83 | 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)) | |
| 86 | 86 | } |
| 87 | 87 | } |
| 88 | 88 | .buttonStyle(.plain) |
| @@ -99,9 +99,11 @@ private struct HunkView: View { | ||
| 99 | 99 | ScrollView(.horizontal) { |
| 100 | 100 | VStack(alignment: .leading, spacing: 0) { |
| 101 | 101 | Text(hunk.header) |
| 102 | .font(.caption2.monospaced()) | |
| 103 | .foregroundStyle(.blue) | |
| 102 | .font(.gbMono(.caption2)) | |
| 103 | .foregroundStyle(.secondary) | |
| 104 | 104 | .padding(.vertical, 2) |
| 105 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 106 | .background(Color.gbFillSubtle) | |
| 105 | 107 | ForEach(hunk.lines) { line in |
| 106 | 108 | LineView(line: line) |
| 107 | 109 | } |
| @@ -129,7 +131,7 @@ private struct LineView: View { | ||
| 129 | 131 | Text(line.text.isEmpty ? " " : line.text) |
| 130 | 132 | .foregroundStyle(textColor) |
| 131 | 133 | } |
| 132 | .font(.caption2.monospaced()) | |
| 134 | .font(.gbMono(.caption2)) | |
| 133 | 135 | .background(background) |
| 134 | 136 | } |
| 135 | 137 | |
| @@ -143,8 +145,8 @@ private struct LineView: View { | ||
| 143 | 145 | |
| 144 | 146 | private var markerColor: Color { |
| 145 | 147 | switch line.kind { |
| 146 | case .addition: .green | |
| 147 | case .deletion: .red | |
| 148 | case .addition: .gbOK | |
| 149 | case .deletion: .gbBad | |
| 148 | 150 | case .context: .clear |
| 149 | 151 | } |
| 150 | 152 | } |
| @@ -155,8 +157,8 @@ private struct LineView: View { | ||
| 155 | 157 | |
| 156 | 158 | private var background: Color { |
| 157 | 159 | 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 | |
| 160 | 162 | case .context: .clear |
| 161 | 163 | } |
| 162 | 164 | } |
gitbay/Views/MRs/MRListView.swift +13 −16
| @@ -94,8 +94,8 @@ private struct MRCreateSheet: View { | ||
| 94 | 94 | if let error = model.errorMessage { |
| 95 | 95 | Section { |
| 96 | 96 | Label(error, systemImage: "exclamationmark.triangle") |
| 97 | .foregroundStyle(.red) | |
| 98 | .font(.subheadline) | |
| 97 | .foregroundStyle(Color.gbBad) | |
| 98 | .font(.gbSans(.subheadline)) | |
| 99 | 99 | } |
| 100 | 100 | } |
| 101 | 101 | } |
| @@ -146,10 +146,10 @@ struct MRRow: View { | ||
| 146 | 146 | VStack(alignment: .leading, spacing: 4) { |
| 147 | 147 | HStack(alignment: .firstTextBaseline, spacing: 6) { |
| 148 | 148 | Text("!\(mr.number)") |
| 149 | .font(.caption.monospaced()) | |
| 149 | .font(.gbMono(.caption)) | |
| 150 | 150 | .foregroundStyle(.secondary) |
| 151 | 151 | Text(mr.title) |
| 152 | .font(.subheadline.weight(.medium)) | |
| 152 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 153 | 153 | .lineLimit(2) |
| 154 | 154 | } |
| 155 | 155 | HStack(spacing: 6) { |
| @@ -157,13 +157,13 @@ struct MRRow: View { | ||
| 157 | 157 | Text(mr.source.isEmpty ? "(source gone)" : mr.source) |
| 158 | 158 | .lineLimit(1) |
| 159 | 159 | Image(systemName: "arrow.right") |
| 160 | .font(.caption2) | |
| 160 | .font(.gbSans(.caption2)) | |
| 161 | 161 | Text(mr.targetRef) |
| 162 | 162 | Spacer() |
| 163 | 163 | Text(mr.createdAt, format: .relative(presentation: .named)) |
| 164 | .foregroundStyle(.tertiary) | |
| 164 | .foregroundStyle(.secondary) | |
| 165 | 165 | } |
| 166 | .font(.caption) | |
| 166 | .font(.gbSans(.caption)) | |
| 167 | 167 | .foregroundStyle(.secondary) |
| 168 | 168 | } |
| 169 | 169 | .padding(.vertical, 2) |
| @@ -174,19 +174,16 @@ struct MRStateBadge: View { | ||
| 174 | 174 | let state: String |
| 175 | 175 | |
| 176 | 176 | 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) | |
| 183 | 178 | } |
| 184 | 179 | |
| 180 | /// The web's chip mapping: open=ok, merged=done, closed=bad, | |
| 181 | /// source_gone=neutral. | |
| 185 | 182 | private var color: Color { |
| 186 | 183 | 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 | |
| 190 | 187 | default: .secondary |
| 191 | 188 | } |
| 192 | 189 | } |
gitbay/Views/MRs/MRView.swift +33 −33
| @@ -24,8 +24,8 @@ struct MRView: View { | ||
| 24 | 24 | if let error = model.actionError { |
| 25 | 25 | Section { |
| 26 | 26 | Label(error, systemImage: "hand.raised") |
| 27 | .foregroundStyle(.orange) | |
| 28 | .font(.subheadline) | |
| 27 | .foregroundStyle(Color.gbWarn) | |
| 28 | .font(.gbSans(.subheadline)) | |
| 29 | 29 | } |
| 30 | 30 | } |
| 31 | 31 | |
| @@ -96,23 +96,23 @@ struct MRView: View { | ||
| 96 | 96 | Section { |
| 97 | 97 | VStack(alignment: .leading, spacing: 6) { |
| 98 | 98 | Text(mr.title) |
| 99 | .font(.headline) | |
| 99 | .font(.gbSans(.headline)) | |
| 100 | 100 | HStack(spacing: 6) { |
| 101 | 101 | MRStateBadge(state: mr.state) |
| 102 | 102 | Text(mr.source.isEmpty ? "(source gone)" : mr.source) |
| 103 | 103 | .lineLimit(1) |
| 104 | 104 | Image(systemName: "arrow.right") |
| 105 | .font(.caption2) | |
| 105 | .font(.gbSans(.caption2)) | |
| 106 | 106 | Text(mr.targetRef) |
| 107 | 107 | } |
| 108 | .font(.caption) | |
| 108 | .font(.gbSans(.caption)) | |
| 109 | 109 | .foregroundStyle(.secondary) |
| 110 | 110 | HStack(spacing: 6) { |
| 111 | 111 | Text("by \(mr.author)") |
| 112 | 112 | Text(mr.createdAt, format: .relative(presentation: .named)) |
| 113 | 113 | .foregroundStyle(.tertiary) |
| 114 | 114 | } |
| 115 | .font(.caption) | |
| 115 | .font(.gbSans(.caption)) | |
| 116 | 116 | .foregroundStyle(.secondary) |
| 117 | 117 | } |
| 118 | 118 | .padding(.vertical, 2) |
| @@ -127,12 +127,12 @@ struct MRView: View { | ||
| 127 | 127 | Spacer() |
| 128 | 128 | if let diff = model.diff { |
| 129 | 129 | Text("+\(diff.additions)") |
| 130 | .foregroundStyle(.green) | |
| 130 | .foregroundStyle(Color.gbOK) | |
| 131 | 131 | Text("−\(diff.deletions)") |
| 132 | .foregroundStyle(.red) | |
| 132 | .foregroundStyle(Color.gbBad) | |
| 133 | 133 | } |
| 134 | 134 | } |
| 135 | .font(.subheadline) | |
| 135 | .font(.gbSans(.subheadline)) | |
| 136 | 136 | } |
| 137 | 137 | } |
| 138 | 138 | } |
| @@ -142,10 +142,10 @@ struct MRView: View { | ||
| 142 | 142 | ForEach(commits) { commit in |
| 143 | 143 | HStack(spacing: 8) { |
| 144 | 144 | Text(commit.shortSHA) |
| 145 | .font(.caption.monospaced()) | |
| 145 | .font(.gbMono(.caption)) | |
| 146 | 146 | .foregroundStyle(.secondary) |
| 147 | 147 | Text(commit.subject) |
| 148 | .font(.subheadline) | |
| 148 | .font(.gbSans(.subheadline)) | |
| 149 | 149 | .lineLimit(1) |
| 150 | 150 | } |
| 151 | 151 | } |
| @@ -159,10 +159,10 @@ struct MRView: View { | ||
| 159 | 159 | Image(systemName: checkIcon(check.state)) |
| 160 | 160 | .foregroundStyle(checkColor(check.state)) |
| 161 | 161 | Text(check.context) |
| 162 | .font(.subheadline) | |
| 162 | .font(.gbSans(.subheadline)) | |
| 163 | 163 | Spacer() |
| 164 | 164 | Text(check.state) |
| 165 | .font(.caption) | |
| 165 | .font(.gbSans(.caption)) | |
| 166 | 166 | .foregroundStyle(.secondary) |
| 167 | 167 | } |
| 168 | 168 | } |
| @@ -175,16 +175,16 @@ struct MRView: View { | ||
| 175 | 175 | HStack { |
| 176 | 176 | Image(systemName: review.verdict == "approve" |
| 177 | 177 | ? "checkmark.circle.fill" : "exclamationmark.circle.fill") |
| 178 | .foregroundStyle(review.verdict == "approve" ? .green : .orange) | |
| 178 | .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn) | |
| 179 | 179 | Text(review.reviewer) |
| 180 | .font(.subheadline) | |
| 180 | .font(.gbSans(.subheadline)) | |
| 181 | 181 | Spacer() |
| 182 | 182 | if review.stale { |
| 183 | 183 | Text("stale") |
| 184 | .font(.caption2) | |
| 184 | .font(.gbSans(.caption2)) | |
| 185 | 185 | .padding(.horizontal, 5) |
| 186 | 186 | .padding(.vertical, 1) |
| 187 | .background(.quaternary, in: Capsule()) | |
| 187 | .background(.quaternary, in: gbChipShape) | |
| 188 | 188 | } |
| 189 | 189 | } |
| 190 | 190 | } |
| @@ -205,13 +205,13 @@ struct MRView: View { | ||
| 205 | 205 | VStack(alignment: .leading, spacing: 4) { |
| 206 | 206 | HStack { |
| 207 | 207 | Text(comment.author) |
| 208 | .font(.caption.weight(.semibold)) | |
| 208 | .font(.gbSans(.caption).weight(.semibold)) | |
| 209 | 209 | Text(comment.createdAt, format: .relative(presentation: .named)) |
| 210 | .font(.caption) | |
| 210 | .font(.gbSans(.caption)) | |
| 211 | 211 | .foregroundStyle(.tertiary) |
| 212 | 212 | } |
| 213 | 213 | MarkdownView(markdown: comment.body) |
| 214 | .font(.subheadline) | |
| 214 | .font(.gbSans(.subheadline)) | |
| 215 | 215 | } |
| 216 | 216 | .padding(.vertical, 2) |
| 217 | 217 | } |
| @@ -290,9 +290,9 @@ struct MRView: View { | ||
| 290 | 290 | |
| 291 | 291 | private func checkColor(_ state: String) -> Color { |
| 292 | 292 | 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 | |
| 296 | 296 | default: .secondary |
| 297 | 297 | } |
| 298 | 298 | } |
| @@ -310,23 +310,23 @@ private struct ThreadView: View { | ||
| 310 | 310 | HStack(spacing: 6) { |
| 311 | 311 | Image(systemName: thread.isResolved |
| 312 | 312 | ? "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) | |
| 315 | 315 | Text("\(thread.path):\(thread.line)") |
| 316 | .font(.caption.monospaced()) | |
| 316 | .font(.gbMono(.caption)) | |
| 317 | 317 | .lineLimit(1) |
| 318 | 318 | if thread.stale { |
| 319 | 319 | Text("stale") |
| 320 | .font(.caption2) | |
| 320 | .font(.gbSans(.caption2)) | |
| 321 | 321 | .padding(.horizontal, 5) |
| 322 | 322 | .padding(.vertical, 1) |
| 323 | .background(.quaternary, in: Capsule()) | |
| 323 | .background(.quaternary, in: gbChipShape) | |
| 324 | 324 | } |
| 325 | 325 | Spacer() |
| 326 | 326 | Button(thread.isResolved ? "Reopen" : "Resolve") { |
| 327 | 327 | Task { await model.setResolved(thread, !thread.isResolved) } |
| 328 | 328 | } |
| 329 | .font(.caption) | |
| 329 | .font(.gbSans(.caption)) | |
| 330 | 330 | .buttonStyle(.bordered) |
| 331 | 331 | .disabled(model.working) |
| 332 | 332 | } |
| @@ -334,19 +334,19 @@ private struct ThreadView: View { | ||
| 334 | 334 | VStack(alignment: .leading, spacing: 2) { |
| 335 | 335 | HStack { |
| 336 | 336 | Text(comment.author) |
| 337 | .font(.caption.weight(.semibold)) | |
| 337 | .font(.gbSans(.caption).weight(.semibold)) | |
| 338 | 338 | Text(comment.createdAt, format: .relative(presentation: .named)) |
| 339 | .font(.caption2) | |
| 339 | .font(.gbSans(.caption2)) | |
| 340 | 340 | .foregroundStyle(.tertiary) |
| 341 | 341 | } |
| 342 | 342 | Text(comment.body) |
| 343 | .font(.subheadline) | |
| 343 | .font(.gbSans(.subheadline)) | |
| 344 | 344 | } |
| 345 | 345 | } |
| 346 | 346 | if !thread.isResolved { |
| 347 | 347 | HStack { |
| 348 | 348 | TextField("Reply", text: $replyText, axis: .vertical) |
| 349 | .font(.subheadline) | |
| 349 | .font(.gbSans(.subheadline)) | |
| 350 | 350 | .lineLimit(1...4) |
| 351 | 351 | Button { |
| 352 | 352 | let text = replyText |
gitbay/Views/Orgs/OrgView.swift +6 −6
| @@ -21,8 +21,8 @@ struct OrgView: View { | ||
| 21 | 21 | if let error = model.actionError { |
| 22 | 22 | Section { |
| 23 | 23 | Label(error, systemImage: "hand.raised") |
| 24 | .foregroundStyle(.orange) | |
| 25 | .font(.subheadline) | |
| 24 | .foregroundStyle(Color.gbWarn) | |
| 25 | .font(.gbSans(.subheadline)) | |
| 26 | 26 | } |
| 27 | 27 | } |
| 28 | 28 | membersSection(loaded.members) |
| @@ -72,7 +72,7 @@ struct OrgView: View { | ||
| 72 | 72 | HStack { |
| 73 | 73 | NavigationLink(value: RepoRoute.profile(member.user)) { |
| 74 | 74 | Text(member.user) |
| 75 | .font(.subheadline) | |
| 75 | .font(.gbSans(.subheadline)) | |
| 76 | 76 | } |
| 77 | 77 | Spacer() |
| 78 | 78 | Menu { |
| @@ -80,10 +80,10 @@ struct OrgView: View { | ||
| 80 | 80 | Button("Member") { Task { await model.setRole(member, role: "member") } } |
| 81 | 81 | } label: { |
| 82 | 82 | Text(member.role) |
| 83 | .font(.caption) | |
| 83 | .font(.gbSans(.caption)) | |
| 84 | 84 | .padding(.horizontal, 6) |
| 85 | 85 | .padding(.vertical, 2) |
| 86 | .background(.quaternary, in: Capsule()) | |
| 86 | .background(.quaternary, in: gbChipShape) | |
| 87 | 87 | } |
| 88 | 88 | .disabled(model.working) |
| 89 | 89 | } |
| @@ -121,7 +121,7 @@ struct OrgView: View { | ||
| 121 | 121 | ForEach(teams, id: \.self) { team in |
| 122 | 122 | NavigationLink(value: OrgRoute.team(org: model.orgName, team: team)) { |
| 123 | 123 | Label(team, systemImage: "person.3") |
| 124 | .font(.subheadline) | |
| 124 | .font(.gbSans(.subheadline)) | |
| 125 | 125 | } |
| 126 | 126 | .swipeActions { |
| 127 | 127 | Button("Delete", role: .destructive) { |
gitbay/Views/Orgs/TeamView.swift +6 −6
| @@ -18,15 +18,15 @@ struct TeamView: View { | ||
| 18 | 18 | if let error = model.actionError { |
| 19 | 19 | Section { |
| 20 | 20 | Label(error, systemImage: "hand.raised") |
| 21 | .foregroundStyle(.orange) | |
| 22 | .font(.subheadline) | |
| 21 | .foregroundStyle(Color.gbWarn) | |
| 22 | .font(.gbSans(.subheadline)) | |
| 23 | 23 | } |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | 26 | Section("Members") { |
| 27 | 27 | ForEach(loaded.members ?? [], id: \.self) { user in |
| 28 | 28 | Text(user) |
| 29 | .font(.subheadline) | |
| 29 | .font(.gbSans(.subheadline)) | |
| 30 | 30 | .swipeActions { |
| 31 | 31 | Button("Remove", role: .destructive) { |
| 32 | 32 | Task { await model.removeMember(user) } |
| @@ -53,14 +53,14 @@ struct TeamView: View { | ||
| 53 | 53 | ForEach(loaded.grants ?? []) { grant in |
| 54 | 54 | HStack { |
| 55 | 55 | Text(grant.repo) |
| 56 | .font(.subheadline) | |
| 56 | .font(.gbSans(.subheadline)) | |
| 57 | 57 | .lineLimit(1) |
| 58 | 58 | Spacer() |
| 59 | 59 | Text(grant.role) |
| 60 | .font(.caption) | |
| 60 | .font(.gbSans(.caption)) | |
| 61 | 61 | .padding(.horizontal, 6) |
| 62 | 62 | .padding(.vertical, 2) |
| 63 | .background(.quaternary, in: Capsule()) | |
| 63 | .background(.quaternary, in: gbChipShape) | |
| 64 | 64 | } |
| 65 | 65 | .swipeActions { |
| 66 | 66 | Button("Revoke", role: .destructive) { |
gitbay/Views/Releases/ReleaseListView.swift +6 −6
| @@ -18,20 +18,20 @@ struct ReleaseListView: View { | ||
| 18 | 18 | NavigationLink(value: ReleaseRoute.release(repo: model.repoPath, tag: release.tag)) { |
| 19 | 19 | VStack(alignment: .leading, spacing: 3) { |
| 20 | 20 | Text(release.title.isEmpty ? release.tag : release.title) |
| 21 | .font(.subheadline.weight(.medium)) | |
| 21 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 22 | 22 | .lineLimit(2) |
| 23 | 23 | HStack(spacing: 6) { |
| 24 | 24 | Text(release.tag) |
| 25 | .font(.caption.monospaced()) | |
| 25 | .font(.gbMono(.caption)) | |
| 26 | 26 | .foregroundStyle(.secondary) |
| 27 | 27 | if let author = release.author { |
| 28 | 28 | Text("by \(author)") |
| 29 | .font(.caption) | |
| 29 | .font(.gbSans(.caption)) | |
| 30 | 30 | .foregroundStyle(.secondary) |
| 31 | 31 | } |
| 32 | 32 | Spacer() |
| 33 | 33 | Text(release.createdAt, format: .relative(presentation: .named)) |
| 34 | .font(.caption) | |
| 34 | .font(.gbSans(.caption)) | |
| 35 | 35 | .foregroundStyle(.tertiary) |
| 36 | 36 | } |
| 37 | 37 | } |
| @@ -101,8 +101,8 @@ private struct ReleaseCreateSheet: View { | ||
| 101 | 101 | if let error = model.createError { |
| 102 | 102 | Section { |
| 103 | 103 | Label(error, systemImage: "exclamationmark.triangle") |
| 104 | .foregroundStyle(.red) | |
| 105 | .font(.subheadline) | |
| 104 | .foregroundStyle(Color.gbBad) | |
| 105 | .font(.gbSans(.subheadline)) | |
| 106 | 106 | } |
| 107 | 107 | } |
| 108 | 108 | } |
gitbay/Views/Releases/ReleaseView.swift +8 −8
| @@ -19,24 +19,24 @@ struct ReleaseView: View { | ||
| 19 | 19 | if let error = model.actionError { |
| 20 | 20 | Section { |
| 21 | 21 | Label(error, systemImage: "hand.raised") |
| 22 | .foregroundStyle(.orange) | |
| 23 | .font(.subheadline) | |
| 22 | .foregroundStyle(Color.gbWarn) | |
| 23 | .font(.gbSans(.subheadline)) | |
| 24 | 24 | } |
| 25 | 25 | } |
| 26 | 26 | Section { |
| 27 | 27 | VStack(alignment: .leading, spacing: 6) { |
| 28 | 28 | Text(release.title.isEmpty ? release.tag : release.title) |
| 29 | .font(.headline) | |
| 29 | .font(.gbSans(.headline)) | |
| 30 | 30 | HStack(spacing: 6) { |
| 31 | 31 | Text(release.tag) |
| 32 | .font(.caption.monospaced()) | |
| 32 | .font(.gbMono(.caption)) | |
| 33 | 33 | if let author = release.author { |
| 34 | 34 | Text("by \(author)") |
| 35 | 35 | } |
| 36 | 36 | Text(release.createdAt, format: .relative(presentation: .named)) |
| 37 | 37 | .foregroundStyle(.tertiary) |
| 38 | 38 | } |
| 39 | .font(.caption) | |
| 39 | .font(.gbSans(.caption)) | |
| 40 | 40 | .foregroundStyle(.secondary) |
| 41 | 41 | } |
| 42 | 42 | .padding(.vertical, 2) |
| @@ -56,16 +56,16 @@ struct ReleaseView: View { | ||
| 56 | 56 | HStack { |
| 57 | 57 | VStack(alignment: .leading, spacing: 2) { |
| 58 | 58 | Text(asset.name) |
| 59 | .font(.caption.monospaced()) | |
| 59 | .font(.gbMono(.caption)) | |
| 60 | 60 | .foregroundStyle(.primary) |
| 61 | 61 | .lineLimit(1) |
| 62 | 62 | Text(String(asset.sha256.prefix(16))) |
| 63 | .font(.caption2.monospaced()) | |
| 63 | .font(.gbMono(.caption2)) | |
| 64 | 64 | .foregroundStyle(.tertiary) |
| 65 | 65 | } |
| 66 | 66 | Spacer() |
| 67 | 67 | Text(asset.size.formatted(.byteCount(style: .file))) |
| 68 | .font(.caption) | |
| 68 | .font(.gbSans(.caption)) | |
| 69 | 69 | .foregroundStyle(.secondary) |
| 70 | 70 | Image(systemName: "arrow.down.circle") |
| 71 | 71 | .foregroundStyle(.secondary) |
gitbay/Views/Repos/FileView.swift +5 −5
| @@ -40,7 +40,7 @@ struct FileView: View { | ||
| 40 | 40 | "Truncated — showing the first \(Int64(file.size).formatted(.byteCount(style: .file))).", |
| 41 | 41 | systemImage: "scissors" |
| 42 | 42 | ) |
| 43 | .font(.caption) | |
| 43 | .font(.gbSans(.caption)) | |
| 44 | 44 | .frame(maxWidth: .infinity, alignment: .leading) |
| 45 | 45 | .padding(8) |
| 46 | 46 | .background(.yellow.opacity(0.15)) |
| @@ -64,13 +64,13 @@ private struct CodeScrollView: View { | ||
| 64 | 64 | .padding(12) |
| 65 | 65 | .frame(maxWidth: .infinity, alignment: .leading) |
| 66 | 66 | } |
| 67 | .font(.caption.monospaced()) | |
| 67 | .font(.gbMono(.caption)) | |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | 70 | 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) | |
| 74 | 74 | let highlighter = SyntaxHighlighter(colorScheme: colorScheme) |
| 75 | 75 | if let highlighted = highlighter.highlight(text, fileName: fileName, font: font) { |
| 76 | 76 | return Text(AttributedString(highlighted)) |
gitbay/Views/Repos/LogView.swift +11 −11
| @@ -28,21 +28,21 @@ struct CommitRow: View { | ||
| 28 | 28 | var body: some View { |
| 29 | 29 | VStack(alignment: .leading, spacing: 4) { |
| 30 | 30 | Text(commit.subject) |
| 31 | .font(.subheadline.weight(.medium)) | |
| 31 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 32 | 32 | .lineLimit(2) |
| 33 | 33 | HStack(spacing: 6) { |
| 34 | 34 | Text(commit.shortSHA) |
| 35 | .font(.caption.monospaced()) | |
| 35 | .font(.gbMono(.caption)) | |
| 36 | 36 | .foregroundStyle(.secondary) |
| 37 | 37 | SignatureBadge(signature: commit.signature) |
| 38 | 38 | Spacer() |
| 39 | 39 | Text(commit.authorName) |
| 40 | .font(.caption) | |
| 40 | .font(.gbSans(.caption)) | |
| 41 | 41 | .foregroundStyle(.secondary) |
| 42 | 42 | .lineLimit(1) |
| 43 | 43 | Text(commit.date, format: .relative(presentation: .named)) |
| 44 | .font(.caption) | |
| 45 | .foregroundStyle(.tertiary) | |
| 44 | .font(.gbSans(.caption)) | |
| 45 | .foregroundStyle(.secondary) | |
| 46 | 46 | } |
| 47 | 47 | } |
| 48 | 48 | .padding(.vertical, 2) |
| @@ -61,7 +61,7 @@ struct SignatureBadge: View { | ||
| 61 | 61 | } icon: { |
| 62 | 62 | Image(systemName: icon) |
| 63 | 63 | } |
| 64 | .font(.caption2) | |
| 64 | .font(.gbSans(.caption2)) | |
| 65 | 65 | .foregroundStyle(color) |
| 66 | 66 | .labelStyle(.titleAndIcon) |
| 67 | 67 | } |
| @@ -74,17 +74,17 @@ struct SignatureBadge: View { | ||
| 74 | 74 | case .unsigned: |
| 75 | 75 | nil |
| 76 | 76 | case .verified: |
| 77 | ("checkmark.seal.fill", .green, signature.signer ?? "verified") | |
| 77 | ("checkmark.seal.fill", .gbOK, signature.signer ?? "verified") | |
| 78 | 78 | case .signedUnknownKey: |
| 79 | 79 | ("questionmark.diamond", .secondary, "unknown key") |
| 80 | 80 | case .signedEmailMismatch: |
| 81 | ("exclamationmark.triangle", .orange, "email mismatch") | |
| 81 | ("exclamationmark.triangle", .gbWarn, "email mismatch") | |
| 82 | 82 | case .signedKeyExpired: |
| 83 | ("clock.badge.exclamationmark", .orange, "key expired") | |
| 83 | ("clock.badge.exclamationmark", .gbWarn, "key expired") | |
| 84 | 84 | case .signedKeyRevoked: |
| 85 | ("xmark.seal", .red, "key revoked") | |
| 85 | ("xmark.seal", .gbBad, "key revoked") | |
| 86 | 86 | case .badSignature: |
| 87 | ("xmark.seal.fill", .red, "bad signature") | |
| 87 | ("xmark.seal.fill", .gbBad, "bad signature") | |
| 88 | 88 | case .unrecognized(let state): |
| 89 | 89 | ("questionmark.circle", .secondary, state) |
| 90 | 90 | } |
gitbay/Views/Repos/MarkdownView.swift +2 −2
| @@ -119,10 +119,10 @@ struct MarkdownView: View { | ||
| 119 | 119 | case .code(let code): |
| 120 | 120 | ScrollView(.horizontal) { |
| 121 | 121 | Text(code) |
| 122 | .font(.caption.monospaced()) | |
| 122 | .font(.gbMono(.caption)) | |
| 123 | 123 | .padding(10) |
| 124 | 124 | } |
| 125 | .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8)) | |
| 125 | .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2)) | |
| 126 | 126 | case .quote(let text): |
| 127 | 127 | HStack(spacing: 10) { |
| 128 | 128 | RoundedRectangle(cornerRadius: 2) |
gitbay/Views/Repos/RepoListView.swift +7 −7
| @@ -81,8 +81,8 @@ private struct RepoCreateSheet: View { | ||
| 81 | 81 | if let error = model.errorMessage { |
| 82 | 82 | Section { |
| 83 | 83 | Label(error, systemImage: "exclamationmark.triangle") |
| 84 | .foregroundStyle(.red) | |
| 85 | .font(.subheadline) | |
| 84 | .foregroundStyle(Color.gbBad) | |
| 85 | .font(.gbSans(.subheadline)) | |
| 86 | 86 | } |
| 87 | 87 | } |
| 88 | 88 | } |
| @@ -128,24 +128,24 @@ private struct RepoRow: View { | ||
| 128 | 128 | VStack(alignment: .leading, spacing: 3) { |
| 129 | 129 | HStack(spacing: 6) { |
| 130 | 130 | Text(repo.path) |
| 131 | .font(.body.weight(.medium)) | |
| 131 | .font(.gbSans(.body).weight(.medium)) | |
| 132 | 132 | .lineLimit(1) |
| 133 | 133 | if repo.isPrivate { |
| 134 | 134 | Image(systemName: "lock.fill") |
| 135 | .font(.caption2) | |
| 135 | .font(.gbSans(.caption2)) | |
| 136 | 136 | .foregroundStyle(.secondary) |
| 137 | 137 | } |
| 138 | 138 | if repo.isArchived { |
| 139 | 139 | Text("archived") |
| 140 | .font(.caption2) | |
| 140 | .font(.gbSans(.caption2)) | |
| 141 | 141 | .padding(.horizontal, 5) |
| 142 | 142 | .padding(.vertical, 1) |
| 143 | .background(.quaternary, in: Capsule()) | |
| 143 | .background(.quaternary, in: gbChipShape) | |
| 144 | 144 | } |
| 145 | 145 | } |
| 146 | 146 | if let description = repo.description, !description.isEmpty { |
| 147 | 147 | Text(description) |
| 148 | .font(.subheadline) | |
| 148 | .font(.gbSans(.subheadline)) | |
| 149 | 149 | .foregroundStyle(.secondary) |
| 150 | 150 | .lineLimit(2) |
| 151 | 151 | } |
gitbay/Views/Repos/RepoSettingsView.swift +8 −8
| @@ -21,8 +21,8 @@ struct RepoSettingsView: View { | ||
| 21 | 21 | if let error = model.actionError { |
| 22 | 22 | Section { |
| 23 | 23 | Label(error, systemImage: "hand.raised") |
| 24 | .foregroundStyle(.orange) | |
| 25 | .font(.subheadline) | |
| 24 | .foregroundStyle(Color.gbWarn) | |
| 25 | .font(.gbSans(.subheadline)) | |
| 26 | 26 | } |
| 27 | 27 | } |
| 28 | 28 | aboutSection(loaded) |
| @@ -59,7 +59,7 @@ struct RepoSettingsView: View { | ||
| 59 | 59 | Button("Save") { |
| 60 | 60 | Task { await model.setDescription(descriptionText) } |
| 61 | 61 | } |
| 62 | .font(.caption) | |
| 62 | .font(.gbSans(.caption)) | |
| 63 | 63 | .disabled(model.working) |
| 64 | 64 | .accessibilityIdentifier("settings-description-save") |
| 65 | 65 | } |
| @@ -74,7 +74,7 @@ struct RepoSettingsView: View { | ||
| 74 | 74 | Button("Save") { |
| 75 | 75 | Task { await model.setWebsite(websiteText) } |
| 76 | 76 | } |
| 77 | .font(.caption) | |
| 77 | .font(.gbSans(.caption)) | |
| 78 | 78 | .disabled(model.working) |
| 79 | 79 | } |
| 80 | 80 | } |
| @@ -97,10 +97,10 @@ struct RepoSettingsView: View { | ||
| 97 | 97 | } |
| 98 | 98 | .disabled(model.working) |
| 99 | 99 | } |
| 100 | .font(.caption) | |
| 100 | .font(.gbSans(.caption)) | |
| 101 | 101 | .padding(.horizontal, 8) |
| 102 | 102 | .padding(.vertical, 3) |
| 103 | .background(.quaternary, in: Capsule()) | |
| 103 | .background(.quaternary, in: gbChipShape) | |
| 104 | 104 | } |
| 105 | 105 | } |
| 106 | 106 | } |
| @@ -143,12 +143,12 @@ struct RepoSettingsView: View { | ||
| 143 | 143 | ForEach(loaded.settings.branches, id: \.self) { branch in |
| 144 | 144 | HStack { |
| 145 | 145 | Label(branch, systemImage: "lock.shield") |
| 146 | .font(.subheadline) | |
| 146 | .font(.gbSans(.subheadline)) | |
| 147 | 147 | Spacer() |
| 148 | 148 | Button("Unprotect") { |
| 149 | 149 | Task { await model.unprotectBranch(branch) } |
| 150 | 150 | } |
| 151 | .font(.caption) | |
| 151 | .font(.gbSans(.caption)) | |
| 152 | 152 | .disabled(model.working) |
| 153 | 153 | } |
| 154 | 154 | } |
gitbay/Views/Repos/RepoView.swift +9 −9
| @@ -18,8 +18,8 @@ struct RepoView: View { | ||
| 18 | 18 | if let error = model.actionError { |
| 19 | 19 | Section { |
| 20 | 20 | Label(error, systemImage: "hand.raised") |
| 21 | .foregroundStyle(.orange) | |
| 22 | .font(.subheadline) | |
| 21 | .foregroundStyle(Color.gbWarn) | |
| 22 | .font(.gbSans(.subheadline)) | |
| 23 | 23 | } |
| 24 | 24 | } |
| 25 | 25 | header(detail) |
| @@ -123,28 +123,28 @@ struct RepoView: View { | ||
| 123 | 123 | VStack(alignment: .leading, spacing: 8) { |
| 124 | 124 | HStack(spacing: 6) { |
| 125 | 125 | Text(detail.path) |
| 126 | .font(.headline) | |
| 126 | .font(.gbSans(.headline)) | |
| 127 | 127 | if detail.visibility == "private" { |
| 128 | 128 | Image(systemName: "lock.fill") |
| 129 | .font(.caption) | |
| 129 | .font(.gbSans(.caption)) | |
| 130 | 130 | .foregroundStyle(.secondary) |
| 131 | 131 | } |
| 132 | 132 | if detail.isArchived { |
| 133 | 133 | Text("archived") |
| 134 | .font(.caption2) | |
| 134 | .font(.gbSans(.caption2)) | |
| 135 | 135 | .padding(.horizontal, 5) |
| 136 | 136 | .padding(.vertical, 1) |
| 137 | .background(.quaternary, in: Capsule()) | |
| 137 | .background(.quaternary, in: gbChipShape) | |
| 138 | 138 | } |
| 139 | 139 | } |
| 140 | 140 | if let description = detail.description, !description.isEmpty { |
| 141 | 141 | Text(description) |
| 142 | .font(.subheadline) | |
| 142 | .font(.gbSans(.subheadline)) | |
| 143 | 143 | .foregroundStyle(.secondary) |
| 144 | 144 | } |
| 145 | 145 | if let website = detail.website, let url = URL(string: website) { |
| 146 | 146 | Link(website, destination: url) |
| 147 | .font(.caption) | |
| 147 | .font(.gbSans(.caption)) | |
| 148 | 148 | .lineLimit(1) |
| 149 | 149 | } |
| 150 | 150 | HStack(spacing: 12) { |
| @@ -154,7 +154,7 @@ struct RepoView: View { | ||
| 154 | 154 | .lineLimit(1) |
| 155 | 155 | } |
| 156 | 156 | } |
| 157 | .font(.caption) | |
| 157 | .font(.gbSans(.caption)) | |
| 158 | 158 | .foregroundStyle(.secondary) |
| 159 | 159 | } |
| 160 | 160 | .padding(.vertical, 4) |
gitbay/Views/Repos/TreeView.swift +1 −1
| @@ -57,7 +57,7 @@ private struct EntryRow: View { | ||
| 57 | 57 | Spacer() |
| 58 | 58 | if let size = entry.size, !entry.isDirectory { |
| 59 | 59 | Text(size.formatted(.byteCount(style: .file))) |
| 60 | .font(.caption) | |
| 60 | .font(.gbSans(.caption)) | |
| 61 | 61 | .foregroundStyle(.tertiary) |
| 62 | 62 | .monospacedDigit() |
| 63 | 63 | } |
gitbay/Views/Shared/ComposeSheet.swift +3 −3
| @@ -27,15 +27,15 @@ struct ComposeSheet: View { | ||
| 27 | 27 | Section("Body") { |
| 28 | 28 | TextEditor(text: $bodyText) |
| 29 | 29 | .frame(minHeight: 160) |
| 30 | .font(.body) | |
| 30 | .font(.gbSans(.body)) | |
| 31 | 31 | .autocorrectionDisabled() |
| 32 | 32 | .accessibilityIdentifier("compose-body") |
| 33 | 33 | } |
| 34 | 34 | if let errorMessage { |
| 35 | 35 | Section { |
| 36 | 36 | Label(errorMessage, systemImage: "exclamationmark.triangle") |
| 37 | .foregroundStyle(.red) | |
| 38 | .font(.subheadline) | |
| 37 | .foregroundStyle(Color.gbBad) | |
| 38 | .font(.gbSans(.subheadline)) | |
| 39 | 39 | } |
| 40 | 40 | } |
| 41 | 41 | } |
gitbay/Views/SignInView.swift +2 −2
| @@ -44,7 +44,7 @@ struct SignInView: View { | ||
| 44 | 44 | VStack(alignment: .leading, spacing: 8) { |
| 45 | 45 | Text("Mint one over SSH on a machine that has your key:") |
| 46 | 46 | Text(verbatim: "gitbay auth token create --name iphone --scope full --ttl 90d") |
| 47 | .font(.caption.monospaced()) | |
| 47 | .font(.gbMono(.caption)) | |
| 48 | 48 | .textSelection(.enabled) |
| 49 | 49 | Text("`--scope read` also works, but a read-only token cannot comment or merge.") |
| 50 | 50 | } |
| @@ -53,7 +53,7 @@ struct SignInView: View { | ||
| 53 | 53 | if let errorMessage { |
| 54 | 54 | Section { |
| 55 | 55 | Label(errorMessage, systemImage: "exclamationmark.triangle") |
| 56 | .foregroundStyle(.red) | |
| 56 | .foregroundStyle(Color.gbBad) | |
| 57 | 57 | } |
| 58 | 58 | } |
| 59 | 59 | |
gitbay/gitbayApp.swift +11
| @@ -5,10 +5,21 @@ struct gitbayApp: App { | ||
| 5 | 5 | |
| 6 | 6 | @State private var session = SessionStore() |
| 7 | 7 | |
| 8 | init() { | |
| 9 | GitbayFonts.register() | |
| 10 | } | |
| 11 | ||
| 8 | 12 | var body: some Scene { |
| 9 | 13 | WindowGroup { |
| 10 | 14 | ContentView() |
| 11 | 15 | .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 | ) | |
| 12 | 23 | } |
| 13 | 24 | } |
| 14 | 25 | } |
gitbayTests/DashboardViewModelTests.swift +12
| @@ -14,11 +14,17 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 14 | 14 | |
| 15 | 15 | private let dashboardJSON = """ |
| 16 | 16 | {"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"}],\ | |
| 17 | 19 | "pinned":[{"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"}],\ |
| 18 | 20 | "open_mrs":[{"repo":"krz/gitbay-ios","number":3,"title":"adopt aggregate","author":"cmc",\ |
| 19 | 21 | "state":"open","updated_at":"2026-08-27T10:00:00.000Z"}],\ |
| 20 | 22 | "assigned_issues":[{"repo":"krz/gitbay","number":11,"title":"iOS app","author":"krz",\ |
| 21 | 23 | "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"}],\ | |
| 22 | 28 | "builds":[{"repo":"krz/gitbay","number":9,"job":"ci","status":"success",\ |
| 23 | 29 | "sha":"65ba14e0000000000000","ref":"refs/heads/main",\ |
| 24 | 30 | "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}]},\ |
| @@ -42,8 +48,11 @@ struct DashboardViewModelTests { | ||
| 42 | 48 | |
| 43 | 49 | let data = try #require(model.state.value) |
| 44 | 50 | #expect(data.pinned.map(\.path) == ["krz/gitbay"]) |
| 51 | #expect(data.reviewQueue.map(\.id) == ["krz/gitbay-ios#3"]) | |
| 45 | 52 | #expect(data.openMRs.map(\.id) == ["krz/gitbay-ios#3"]) |
| 46 | 53 | #expect(data.assignedIssues.first?.number == 11) |
| 54 | #expect(data.openIssues.first?.number == 11) | |
| 55 | #expect(data.recentActivity.first?.phrase == "merged !2") | |
| 47 | 56 | #expect(data.builds.first?.status == "success") |
| 48 | 57 | // The whole screen cost exactly one request. |
| 49 | 58 | #expect(stub.seen.count == 1) |
| @@ -60,6 +69,9 @@ struct DashboardViewModelTests { | ||
| 60 | 69 | let data = try #require(model.state.value) |
| 61 | 70 | #expect(data.pinned.isEmpty) |
| 62 | 71 | #expect(data.openMRs.isEmpty) |
| 72 | #expect(data.reviewQueue.isEmpty) | |
| 73 | #expect(data.openIssues.isEmpty) | |
| 74 | #expect(data.recentActivity.isEmpty) | |
| 63 | 75 | } |
| 64 | 76 | |
| 65 | 77 | @Test func aFailureIsTheScreensState() async throws { |
gitbayTests/DiscoveryTests.swift +3 −2
| @@ -30,9 +30,10 @@ struct FeedEventTests { | ||
| 30 | 30 | |
| 31 | 31 | let build = try decode(""" |
| 32 | 32 | {"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"} | |
| 34 | 34 | """) |
| 35 | #expect(build.phrase == "build #56 succeeded") | |
| 35 | #expect(build.phrase == "build success ci") | |
| 36 | #expect(build.displayActor == "gitbay") | |
| 36 | 37 | #expect(build.destination == .build(repo: "krz/gitbay", number: 56)) |
| 37 | 38 | |
| 38 | 39 | let release = try decode(""" |
gitbayUITests/LiveSmokeUITests.swift +60
| @@ -23,6 +23,11 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 23 | 23 | ) |
| 24 | 24 | continueAfterFailure = false |
| 25 | 25 | 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 | } | |
| 26 | 31 | app.launch() |
| 27 | 32 | } |
| 28 | 33 | |
| @@ -473,3 +478,58 @@ extension LiveSmokeUITests { | ||
| 473 | 478 | XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted") |
| 474 | 479 | } |
| 475 | 480 | } |
| 481 | ||
| 482 | extension 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 | } | |