Commit ad768df3ce
Unsigned
Layout: unified · split
gitbay/ContentView.swift +4
| @@ -80,6 +80,10 @@ private struct RouteDestinations: ViewModifier { | |||
| 80 | GrepView(client: client, repo: repo) | 80 | GrepView(client: client, repo: repo) |
| 81 | case .blame(let repo, let path, let ref): | 81 | case .blame(let repo, let path, let ref): |
| 82 | BlameView(client: client, repo: repo, path: path, ref: ref) | 82 | BlameView(client: client, repo: repo, path: path, ref: ref) |
| 83 | case .commit(let repo, let sha): | ||
| 84 | CommitView(client: client, repo: repo, sha: sha) | ||
| 85 | case .commitDiff(let repo, let sha): | ||
| 86 | CommitDiffView(client: client, repo: repo, sha: sha) | ||
| 83 | case .profile(let name): | 87 | case .profile(let name): |
| 84 | ProfileView(client: client, name: name) | 88 | ProfileView(client: client, name: name) |
| 85 | case .account: | 89 | case .account: |
gitbay/Discovery/ProfileViewModel.swift +40 −8
| @@ -1,8 +1,9 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 2 | import Observation |
| 3 | 3 | ||
| 4 | /// `profile show <name>` plus the profile's reachable repositories via | 4 | /// `profile show <name>` — the whole profile in one read: description, |
| 5 | /// `repo search` filtered to the owner prefix. | 5 | /// links, org membership (or an org's members), the repositories the |
| 6 | /// account may see, and a year of activity. | ||
| 6 | @Observable | 7 | @Observable |
| 7 | @MainActor | 8 | @MainActor |
| 8 | final class ProfileViewModel { | 9 | final class ProfileViewModel { |
| @@ -12,10 +13,46 @@ final class ProfileViewModel { | |||
| 12 | let kind: String // user | org | 13 | let kind: String // user | org |
| 13 | let description: String? | 14 | let description: String? |
| 14 | let website: String? | 15 | let website: String? |
| 16 | let orgs: [Member]? | ||
| 17 | let members: [Member]? | ||
| 18 | let repos: [Repo] | ||
| 19 | let activity: [Day]? | ||
| 20 | let activityTotal: Int | ||
| 21 | |||
| 22 | enum CodingKeys: String, CodingKey { | ||
| 23 | case name, kind, description, website, orgs, members, repos, activity | ||
| 24 | case activityTotal = "activity_total" | ||
| 25 | } | ||
| 26 | |||
| 27 | var isOrg: Bool { kind == "org" } | ||
| 28 | |||
| 29 | nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable { | ||
| 30 | let name: String | ||
| 31 | let role: String? | ||
| 32 | var id: String { name } | ||
| 33 | } | ||
| 34 | |||
| 35 | nonisolated struct Repo: Decodable, Sendable, Hashable, Identifiable { | ||
| 36 | let path: String | ||
| 37 | let visibility: String | ||
| 38 | let description: String? | ||
| 39 | let archived: Bool? | ||
| 40 | |||
| 41 | var id: String { path } | ||
| 42 | var isPrivate: Bool { visibility == "private" } | ||
| 43 | var isArchived: Bool { archived ?? false } | ||
| 44 | var name: String { String(path.split(separator: "/").last ?? "") } | ||
| 45 | } | ||
| 46 | |||
| 47 | /// One day's contributions. Days with none are omitted, so the | ||
| 48 | /// calendar is filled in by the view. | ||
| 49 | nonisolated struct Day: Decodable, Sendable, Hashable { | ||
| 50 | let date: String | ||
| 51 | let count: Int | ||
| 52 | } | ||
| 15 | } | 53 | } |
| 16 | 54 | ||
| 17 | private(set) var state: LoadState<Profile> = .loading | 55 | private(set) var state: LoadState<Profile> = .loading |
| 18 | private(set) var repos: [RepoSummary] = [] | ||
| 19 | 56 | ||
| 20 | private let client: GitbayClient | 57 | private let client: GitbayClient |
| 21 | let name: String | 58 | let name: String |
| @@ -30,11 +67,6 @@ final class ProfileViewModel { | |||
| 30 | state = .loaded(try await client.read(["profile", "show", name], as: Profile.self)) | 67 | state = .loaded(try await client.read(["profile", "show", name], as: Profile.self)) |
| 31 | } catch { | 68 | } catch { |
| 32 | state = .from(error) | 69 | state = .from(error) |
| 33 | return | ||
| 34 | } | 70 | } |
| 35 | // Search matches on path, so the owner name surfaces their repos; | ||
| 36 | // keep only true prefix matches. | ||
| 37 | repos = ((try? await client.readList(["repo", "search", name], of: RepoSummary.self)) ?? []) | ||
| 38 | .filter { $0.owner == name } | ||
| 39 | } | 71 | } |
| 40 | } | 72 | } |
gitbay/Repos/CommitDetailViewModel.swift added +64
| @@ -0,0 +1,64 @@ | |||
| 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 | /// like the logged-in web dashboard. | 4 | /// like the logged-in web dashboard. |
| 5 | struct DashboardView: View { | 5 | struct DashboardView: View { |
| 6 | 6 | ||
| 7 | @Environment(SessionStore.self) private var session | ||
| 7 | @State private var model: DashboardViewModel | 8 | @State private var model: DashboardViewModel |
| 9 | @State private var createModel: RepoCreateViewModel | ||
| 10 | @State private var composing = false | ||
| 8 | 11 | ||
| 9 | init(client: GitbayClient) { | 12 | init(client: GitbayClient) { |
| 10 | _model = State(initialValue: DashboardViewModel(client: client)) | 13 | _model = State(initialValue: DashboardViewModel(client: client)) |
| 14 | _createModel = State(initialValue: RepoCreateViewModel(client: client)) | ||
| 11 | } | 15 | } |
| 12 | 16 | ||
| 13 | var body: some View { | 17 | var body: some View { |
| @@ -41,13 +45,24 @@ struct DashboardView: View { | |||
| 41 | .navigationTitle("Dashboard") | 45 | .navigationTitle("Dashboard") |
| 42 | .toolbar { | 46 | .toolbar { |
| 43 | ToolbarItem(placement: .topBarTrailing) { | 47 | ToolbarItem(placement: .topBarTrailing) { |
| 44 | NavigationLink(value: RepoRoute.account) { | 48 | Button { |
| 45 | Image(systemName: "key") | 49 | composing = true |
| 50 | } label: { | ||
| 51 | Image(systemName: "plus") | ||
| 46 | } | 52 | } |
| 47 | .accessibilityIdentifier("account-screen-link") | 53 | .accessibilityIdentifier("dashboard-create-button") |
| 48 | } | 54 | } |
| 49 | AccountMenu() | 55 | AccountMenu() |
| 50 | } | 56 | } |
| 57 | .sheet(isPresented: $composing) { | ||
| 58 | RepoCreateSheet( | ||
| 59 | model: createModel, | ||
| 60 | ownerPrefix: session.current?.username ?? "" | ||
| 61 | ) { | ||
| 62 | composing = false | ||
| 63 | Task { await model.load() } | ||
| 64 | } | ||
| 65 | } | ||
| 51 | .task { await model.load() } | 66 | .task { await model.load() } |
| 52 | .refreshable { await model.load() } | 67 | .refreshable { await model.load() } |
| 53 | } | 68 | } |
gitbay/Views/Discovery/ActivityGraph.swift added +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 | import SwiftUI | 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 | struct ProfileView: View { | 5 | struct ProfileView: View { |
| 5 | 6 | ||
| 6 | @State private var model: ProfileViewModel | 7 | @State private var model: ProfileViewModel |
| @@ -12,67 +13,126 @@ struct ProfileView: View { | |||
| 12 | var body: some View { | 13 | var body: some View { |
| 13 | List { | 14 | List { |
| 14 | if let profile = model.state.value { | 15 | if let profile = model.state.value { |
| 15 | Section { | 16 | header(profile) |
| 16 | VStack(alignment: .leading, spacing: 6) { | 17 | peopleSection(profile) |
| 17 | HStack(spacing: 8) { | 18 | activitySection(profile) |
| 18 | Image(systemName: profile.kind == "org" | 19 | reposSection(profile) |
| 19 | ? "building.2" : "person.crop.circle") | 20 | if profile.isOrg { |
| 20 | .font(.gbSans(.title2)) | 21 | Section { |
| 21 | .foregroundStyle(.secondary) | 22 | NavigationLink(value: OrgRoute.org(profile.name)) { |
| 22 | Text(profile.name) | 23 | Label("Members & Teams", systemImage: "person.3") |
| 23 | .font(.gbSans(.title3).weight(.semibold)) | ||
| 24 | Text(profile.kind) | ||
| 25 | .font(.gbSans(.caption2)) | ||
| 26 | .padding(.horizontal, 6) | ||
| 27 | .padding(.vertical, 1) | ||
| 28 | .background(.quaternary, in: gbChipShape) | ||
| 29 | } | ||
| 30 | if let description = profile.description, !description.isEmpty { | ||
| 31 | Text(description) | ||
| 32 | .font(.gbSans(.subheadline)) | ||
| 33 | .foregroundStyle(.secondary) | ||
| 34 | } | ||
| 35 | if let website = profile.website, let url = URL(string: website) { | ||
| 36 | Link(website, destination: url) | ||
| 37 | .font(.gbSans(.caption)) | ||
| 38 | .lineLimit(1) | ||
| 39 | } | 24 | } |
| 40 | } | 25 | } |
| 41 | .padding(.vertical, 4) | ||
| 42 | } | 26 | } |
| 27 | } | ||
| 28 | } | ||
| 29 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 30 | .navigationTitle(model.name) | ||
| 31 | .navigationBarTitleDisplayMode(.inline) | ||
| 32 | .task { await model.load() } | ||
| 33 | .refreshable { await model.load() } | ||
| 34 | } | ||
| 43 | 35 | ||
| 44 | if profile.kind == "org" { | 36 | // MARK: - Sections |
| 45 | Section { | 37 | |
| 46 | NavigationLink(value: OrgRoute.org(profile.name)) { | 38 | private func header(_ profile: ProfileViewModel.Profile) -> some View { |
| 47 | Label("Members & Teams", systemImage: "person.3") | 39 | Section { |
| 40 | VStack(alignment: .leading, spacing: 8) { | ||
| 41 | HStack(spacing: 8) { | ||
| 42 | Image(systemName: profile.isOrg ? "building.2" : "person.crop.circle") | ||
| 43 | .font(.title2) | ||
| 44 | .foregroundStyle(Color.gbAccent) | ||
| 45 | Text(profile.name) | ||
| 46 | .font(.gbSans(.title3).weight(.semibold)) | ||
| 47 | GBChip(profile.kind, .secondary) | ||
| 48 | } | ||
| 49 | if let description = profile.description, !description.isEmpty { | ||
| 50 | Text(description) | ||
| 51 | .font(.gbSans(.subheadline)) | ||
| 52 | .foregroundStyle(.secondary) | ||
| 53 | } | ||
| 54 | if let website = profile.website, !website.isEmpty, | ||
| 55 | let url = URL(string: website) { | ||
| 56 | Link(destination: url) { | ||
| 57 | Label(website, systemImage: "link") | ||
| 58 | .font(.gbSans(.caption)) | ||
| 59 | .lineLimit(1) | ||
| 60 | } | ||
| 61 | } | ||
| 62 | } | ||
| 63 | .padding(.vertical, 4) | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | /// A user's organizations, or an organization's members — either way, | ||
| 68 | /// who they work with, each one openable. | ||
| 69 | @ViewBuilder | ||
| 70 | private func peopleSection(_ profile: ProfileViewModel.Profile) -> some View { | ||
| 71 | let people = profile.isOrg ? (profile.members ?? []) : (profile.orgs ?? []) | ||
| 72 | if !people.isEmpty { | ||
| 73 | Section(profile.isOrg ? "Members" : "Organizations") { | ||
| 74 | ForEach(people) { person in | ||
| 75 | NavigationLink(value: RepoRoute.profile(person.name)) { | ||
| 76 | HStack { | ||
| 77 | Label(person.name, systemImage: profile.isOrg | ||
| 78 | ? "person.crop.circle" : "building.2") | ||
| 79 | .font(.gbSans(.subheadline)) | ||
| 80 | Spacer() | ||
| 81 | if let role = person.role, !role.isEmpty { | ||
| 82 | GBChip(role, .secondary) | ||
| 83 | } | ||
| 48 | } | 84 | } |
| 49 | } | 85 | } |
| 50 | } | 86 | } |
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 51 | 90 | ||
| 52 | if !model.repos.isEmpty { | 91 | @ViewBuilder |
| 53 | Section("Repositories") { | 92 | private func activitySection(_ profile: ProfileViewModel.Profile) -> some View { |
| 54 | ForEach(model.repos) { repo in | 93 | if let activity = profile.activity, !activity.isEmpty { |
| 55 | NavigationLink(value: RepoRoute.repo(repo.path)) { | 94 | Section { |
| 56 | VStack(alignment: .leading, spacing: 2) { | 95 | ActivityGraph(days: activity) |
| 57 | Text(repo.name) | 96 | } header: { |
| 58 | .font(.gbSans(.subheadline).weight(.medium)) | 97 | Text("Activity") |
| 59 | if let description = repo.description, !description.isEmpty { | 98 | } footer: { |
| 60 | Text(description) | 99 | Text("\(profile.activityTotal) contributions in the last year.") |
| 61 | .font(.gbSans(.caption)) | 100 | } |
| 62 | .foregroundStyle(.secondary) | 101 | } |
| 63 | .lineLimit(1) | 102 | } |
| 64 | } | 103 | |
| 104 | private func reposSection(_ profile: ProfileViewModel.Profile) -> some View { | ||
| 105 | Section("Repositories \(profile.repos.count)") { | ||
| 106 | if profile.repos.isEmpty { | ||
| 107 | Text("No visible repositories.") | ||
| 108 | .font(.gbSans(.subheadline)) | ||
| 109 | .foregroundStyle(.secondary) | ||
| 110 | } else { | ||
| 111 | ForEach(profile.repos) { repo in | ||
| 112 | NavigationLink(value: RepoRoute.repo(repo.path)) { | ||
| 113 | VStack(alignment: .leading, spacing: 3) { | ||
| 114 | HStack(spacing: 6) { | ||
| 115 | Text(repo.name) | ||
| 116 | .font(.gbSans(.subheadline).weight(.medium)) | ||
| 117 | if repo.isPrivate { | ||
| 118 | Image(systemName: "lock.fill") | ||
| 119 | .font(.gbSans(.caption2)) | ||
| 120 | .foregroundStyle(.secondary) | ||
| 65 | } | 121 | } |
| 122 | if repo.isArchived { | ||
| 123 | GBChip("archived", .secondary) | ||
| 124 | } | ||
| 125 | } | ||
| 126 | if let description = repo.description, !description.isEmpty { | ||
| 127 | Text(description) | ||
| 128 | .font(.gbSans(.caption)) | ||
| 129 | .foregroundStyle(.secondary) | ||
| 130 | .lineLimit(2) | ||
| 66 | } | 131 | } |
| 67 | } | 132 | } |
| 68 | } | 133 | } |
| 69 | } | 134 | } |
| 70 | } | 135 | } |
| 71 | } | 136 | } |
| 72 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 73 | .navigationTitle(model.name) | ||
| 74 | .navigationBarTitleDisplayMode(.inline) | ||
| 75 | .task { await model.load() } | ||
| 76 | .refreshable { await model.load() } | ||
| 77 | } | 137 | } |
| 78 | } | 138 | } |
gitbay/Views/MRs/DiffView.swift +12 −9
| @@ -18,7 +18,7 @@ struct DiffView: View { | |||
| 18 | if let diff = model.diff, !diff.files.isEmpty { | 18 | if let diff = model.diff, !diff.files.isEmpty { |
| 19 | List { | 19 | List { |
| 20 | ForEach(diff.files) { file in | 20 | ForEach(diff.files) { file in |
| 21 | FileDiffSection(file: file, model: model) | 21 | DiffFileSection(file: file, model: model) |
| 22 | } | 22 | } |
| 23 | let detached = model.threads.filter { thread in | 23 | let detached = model.threads.filter { thread in |
| 24 | thread.stale || !diff.anchors(thread) | 24 | thread.stale || !diff.anchors(thread) |
| @@ -46,10 +46,11 @@ struct DiffView: View { | |||
| 46 | } | 46 | } |
| 47 | } | 47 | } |
| 48 | 48 | ||
| 49 | private struct FileDiffSection: View { | 49 | struct DiffFileSection: View { |
| 50 | 50 | ||
| 51 | let file: UnifiedDiff.File | 51 | let file: UnifiedDiff.File |
| 52 | let model: MRDetailViewModel | 52 | /// Review threads belong to merge requests; a commit diff has none. |
| 53 | var model: MRDetailViewModel? = nil | ||
| 53 | @State private var collapsed = false | 54 | @State private var collapsed = false |
| 54 | 55 | ||
| 55 | var body: some View { | 56 | var body: some View { |
| @@ -97,7 +98,7 @@ private struct HunkView: View { | |||
| 97 | 98 | ||
| 98 | let file: UnifiedDiff.File | 99 | let file: UnifiedDiff.File |
| 99 | let hunk: UnifiedDiff.Hunk | 100 | let hunk: UnifiedDiff.Hunk |
| 100 | let model: MRDetailViewModel | 101 | let model: MRDetailViewModel? |
| 101 | 102 | ||
| 102 | var body: some View { | 103 | var body: some View { |
| 103 | ScrollView(.horizontal) { | 104 | ScrollView(.horizontal) { |
| @@ -110,11 +111,13 @@ private struct HunkView: View { | |||
| 110 | .background(Color.gbFillSubtle) | 111 | .background(Color.gbFillSubtle) |
| 111 | ForEach(hunk.lines) { line in | 112 | ForEach(hunk.lines) { line in |
| 112 | LineView(line: line) | 113 | LineView(line: line) |
| 113 | ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in | 114 | if let model { |
| 114 | ReviewThreadView(thread: thread, model: model) | 115 | ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in |
| 115 | .padding(.vertical, 6) | 116 | ReviewThreadView(thread: thread, model: model) |
| 116 | .padding(.horizontal, 8) | 117 | .padding(.vertical, 6) |
| 117 | .background(Color.gbFillSubtle) | 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 | var body: some View { | 11 | var body: some View { |
| 12 | List { | 12 | List { |
| 13 | ForEach(model.state.value ?? []) { commit in | 13 | ForEach(model.state.value ?? []) { commit in |
| 14 | CommitRow(commit: commit) | 14 | NavigationLink(value: RepoRoute.commit(repo: model.repoPath, sha: commit.sha)) { |
| 15 | CommitRow(commit: commit) | ||
| 16 | } | ||
| 15 | } | 17 | } |
| 16 | } | 18 | } |
| 17 | .overlay { LoadStateOverlay(state: model.state) } | 19 | .overlay { LoadStateOverlay(state: model.state) } |
gitbay/Views/Repos/RepoListView.swift +22 −7
| @@ -53,7 +53,7 @@ struct RepoListView: View { | |||
| 53 | 53 | ||
| 54 | /// `repo create <owner/name> [--private]` — the name carries the owner, | 54 | /// `repo create <owner/name> [--private]` — the name carries the owner, |
| 55 | /// so org repos are created by typing org/name. | 55 | /// so org repos are created by typing org/name. |
| 56 | private struct RepoCreateSheet: View { | 56 | struct RepoCreateSheet: View { |
| 57 | 57 | ||
| 58 | let model: RepoCreateViewModel | 58 | let model: RepoCreateViewModel |
| 59 | let ownerPrefix: String | 59 | let ownerPrefix: String |
| @@ -164,20 +164,35 @@ struct AccountMenu: ToolbarContent { | |||
| 164 | Menu { | 164 | Menu { |
| 165 | if let current = session.current { | 165 | if let current = session.current { |
| 166 | Section(current.label) { | 166 | Section(current.label) { |
| 167 | Button("Sign Out", role: .destructive) { | 167 | NavigationLink(value: RepoRoute.profile(current.username)) { |
| 168 | session.remove(current) | 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 | 175 | if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty { |
| 173 | Button(account.label) { | 176 | Section("Switch account") { |
| 174 | session.activate(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 | } label: { | 192 | } label: { |
| 179 | Image(systemName: "person.crop.circle") | 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 | case settings(repo: String) | 10 | case settings(repo: String) |
| 11 | case grep(repo: String) | 11 | case grep(repo: String) |
| 12 | case blame(repo: String, path: String, ref: String?) | 12 | case blame(repo: String, path: String, ref: String?) |
| 13 | case commit(repo: String, sha: String) | ||
| 14 | case commitDiff(repo: String, sha: String) | ||
| 13 | case profile(String) | 15 | case profile(String) |
| 14 | case account | 16 | case account |
| 15 | case addAccount | 17 | case addAccount |
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 | @MainActor | 114 | @MainActor |
| 156 | struct RepoSearchTests { | 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 | app.navigationBars.buttons.firstMatch.tap() | 142 | app.navigationBars.buttons.firstMatch.tap() |
| 143 | } | 143 | } |
| 144 | 144 | ||
| 145 | /// Keys, PGP and email live behind the profile menu now, not their | ||
| 146 | /// own toolbar button. | ||
| 147 | func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) { | ||
| 148 | let menu = app.descendants(matching: .any) | ||
| 149 | .matching(identifier: "account-menu").firstMatch | ||
| 150 | XCTAssertTrue(menu.waitForExistence(timeout: 15), | ||
| 151 | "account menu missing", file: file, line: line) | ||
| 152 | menu.tap() | ||
| 153 | let keys = app.buttons["Keys & Email"].firstMatch | ||
| 154 | XCTAssertTrue(keys.waitForExistence(timeout: 5), | ||
| 155 | "Keys & Email missing from the account menu", file: file, line: line) | ||
| 156 | keys.tap() | ||
| 157 | } | ||
| 158 | |||
| 145 | /// Switch tabs and wait until that tab is actually front. A tap | 159 | /// Switch tabs and wait until that tab is actually front. A tap |
| 146 | /// dispatched before the app is interactive — which happens right | 160 | /// dispatched before the app is interactive — which happens right |
| 147 | /// after launch when there is no sign-in to slow things down — is | 161 | /// after launch when there is no sign-in to slow things down — is |
| @@ -416,10 +430,7 @@ extension LiveSmokeUITests { | |||
| 416 | /// added or removed, no mail is sent. | 430 | /// added or removed, no mail is sent. |
| 417 | func testAccountFlows() throws { | 431 | func testAccountFlows() throws { |
| 418 | // Dashboard toolbar -> account screen. | 432 | // Dashboard toolbar -> account screen. |
| 419 | let accountLink = app.descendants(matching: .any) | 433 | openAccountScreen() |
| 420 | .matching(identifier: "account-screen-link").firstMatch | ||
| 421 | XCTAssertTrue(accountLink.waitForExistence(timeout: 10)) | ||
| 422 | accountLink.tap() | ||
| 423 | 434 | ||
| 424 | // Real keys render: SSH fingerprints and the PGP key's UID email. | 435 | // Real keys render: SSH fingerprints and the PGP key's UID email. |
| 425 | XCTAssertTrue(app.staticTexts | 436 | XCTAssertTrue(app.staticTexts |
| @@ -464,10 +475,7 @@ extension LiveSmokeUITests { | |||
| 464 | /// granted a repo, the grant revoked, the team deleted. Everything | 475 | /// granted a repo, the grant revoked, the team deleted. Everything |
| 465 | /// this test makes, it removes. | 476 | /// this test makes, it removes. |
| 466 | func testOrgFlows() throws { | 477 | func testOrgFlows() throws { |
| 467 | let accountLink = app.descendants(matching: .any) | 478 | openAccountScreen() |
| 468 | .matching(identifier: "account-screen-link").firstMatch | ||
| 469 | XCTAssertTrue(accountLink.waitForExistence(timeout: 10)) | ||
| 470 | accountLink.tap() | ||
| 471 | 479 | ||
| 472 | let orgRow = app.staticTexts["krz"].firstMatch | 480 | let orgRow = app.staticTexts["krz"].firstMatch |
| 473 | XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing") | 481 | XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing") |
| @@ -629,3 +637,66 @@ extension LiveSmokeUITests { | |||
| 629 | .waitForExistence(timeout: 20), "edit not reflected after commit") | 637 | .waitForExistence(timeout: 20), "edit not reflected after commit") |
| 630 | } | 638 | } |
| 631 | } | 639 | } |
| 640 | |||
| 641 | |||
| 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 | } | ||