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