Commit 2fe3160315
Verified · cmc
gitbay/ContentView.swift +10
| @@ -13,6 +13,12 @@ struct ContentView: View { | ||
| 13 | 13 | .navigationDestinations(client: client) |
| 14 | 14 | } |
| 15 | 15 | } |
| 16 | Tab("Feed", systemImage: "bolt") { | |
| 17 | NavigationStack { | |
| 18 | FeedView(client: client) | |
| 19 | .navigationDestinations(client: client) | |
| 20 | } | |
| 21 | } | |
| 16 | 22 | Tab("Repositories", systemImage: "books.vertical") { |
| 17 | 23 | NavigationStack { |
| 18 | 24 | RepoListView(client: client) |
| @@ -64,6 +70,10 @@ private struct RouteDestinations: ViewModifier { | ||
| 64 | 70 | LogView(client: client, repo: repo) |
| 65 | 71 | case .settings(let repo): |
| 66 | 72 | RepoSettingsView(client: client, repo: repo) |
| 73 | case .grep(let repo): | |
| 74 | GrepView(client: client, repo: repo) | |
| 75 | case .profile(let name): | |
| 76 | ProfileView(client: client, name: name) | |
| 67 | 77 | case .addAccount: |
| 68 | 78 | SignInView() |
| 69 | 79 | } |
gitbay/Discovery/FeedEvent.swift added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One `feed` event. `data` varies by kind; unknown kinds and fields | |
| 4 | /// must render, not sink the feed. | |
| 5 | nonisolated struct FeedEvent: Decodable, Sendable, Hashable, Identifiable { | |
| 6 | let id: Int64 | |
| 7 | let repo: String | |
| 8 | let actor: String? | |
| 9 | let kind: String | |
| 10 | let data: Payload? | |
| 11 | let createdAt: Date | |
| 12 | ||
| 13 | enum CodingKeys: String, CodingKey { | |
| 14 | case id, repo, actor, kind, data | |
| 15 | case createdAt = "created_at" | |
| 16 | } | |
| 17 | ||
| 18 | nonisolated struct Payload: Decodable, Sendable, Hashable { | |
| 19 | let number: Int64? | |
| 20 | let sha: String? | |
| 21 | let tag: String? | |
| 22 | let title: String? | |
| 23 | } | |
| 24 | ||
| 25 | /// "opened !4", "build #56 succeeded" — the verb phrase after the actor. | |
| 26 | var phrase: String { | |
| 27 | let n = data?.number.map(String.init) ?? "" | |
| 28 | switch kind { | |
| 29 | case "mr.created": return "opened !\(n)" | |
| 30 | 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)" | |
| 35 | 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 "release.created": return "released \(data?.tag ?? "")" | |
| 39 | case "push": return "pushed" | |
| 40 | default: return kind | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | var icon: (name: String, isPositive: Bool?) { | |
| 45 | switch kind { | |
| 46 | case "mr.created": ("arrow.triangle.merge", nil) | |
| 47 | case "mr.merged": ("arrow.triangle.merge", true) | |
| 48 | case "mr.closed": ("xmark.circle", false) | |
| 49 | case "issue.created", "issue.open": ("smallcircle.filled.circle", nil) | |
| 50 | case "issue.closed": ("checkmark.circle", true) | |
| 51 | case "issue.commented": ("bubble.left", nil) | |
| 52 | case "build.success": ("checkmark.circle.fill", true) | |
| 53 | case "build.failure": ("xmark.circle.fill", false) | |
| 54 | case "release.created": ("shippingbox", nil) | |
| 55 | case "push": ("arrow.up.circle", nil) | |
| 56 | default: ("circle", nil) | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | /// Where tapping the row goes, when the event names something the app | |
| 61 | /// has a screen for. | |
| 62 | var destination: FeedDestination? { | |
| 63 | guard let number = data?.number else { | |
| 64 | return .repo(repo) | |
| 65 | } | |
| 66 | switch kind.split(separator: ".").first { | |
| 67 | case "mr": return .mr(repo: repo, number: number) | |
| 68 | case "issue": return .issue(repo: repo, number: number) | |
| 69 | case "build": return .build(repo: repo, number: number) | |
| 70 | default: return .repo(repo) | |
| 71 | } | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | nonisolated enum FeedDestination: Hashable { | |
| 76 | case repo(String) | |
| 77 | case mr(repo: String, number: Int64) | |
| 78 | case issue(repo: String, number: Int64) | |
| 79 | case build(repo: String, number: Int64) | |
| 80 | } | |
gitbay/Discovery/GrepViewModel.swift added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `repo grep <owner/name> <query>` — server-side git grep, literal and | |
| 5 | /// case-insensitive. | |
| 6 | @Observable | |
| 7 | @MainActor | |
| 8 | final class GrepViewModel { | |
| 9 | ||
| 10 | nonisolated struct Match: Decodable, Sendable, Hashable, Identifiable { | |
| 11 | let path: String | |
| 12 | let line: Int | |
| 13 | let text: String | |
| 14 | var id: String { "\(path):\(line)" } | |
| 15 | } | |
| 16 | ||
| 17 | /// nil until the first search; distinct from an empty result. | |
| 18 | private(set) var state: LoadState<[Match]>? | |
| 19 | private(set) var lastQuery = "" | |
| 20 | ||
| 21 | private let client: GitbayClient | |
| 22 | let repoPath: String | |
| 23 | ||
| 24 | init(client: GitbayClient, repoPath: String) { | |
| 25 | self.client = client | |
| 26 | self.repoPath = repoPath | |
| 27 | } | |
| 28 | ||
| 29 | /// Matches grouped by file, in server order. | |
| 30 | var byFile: [(file: String, matches: [Match])] { | |
| 31 | guard let matches = state?.value else { return [] } | |
| 32 | var order: [String] = [] | |
| 33 | var groups: [String: [Match]] = [:] | |
| 34 | for match in matches { | |
| 35 | if groups[match.path] == nil { order.append(match.path) } | |
| 36 | groups[match.path, default: []].append(match) | |
| 37 | } | |
| 38 | return order.map { ($0, groups[$0]!) } | |
| 39 | } | |
| 40 | ||
| 41 | func search(_ query: String) async { | |
| 42 | let trimmed = query.trimmingCharacters(in: .whitespaces) | |
| 43 | guard !trimmed.isEmpty else { return } | |
| 44 | lastQuery = trimmed | |
| 45 | state = .loading | |
| 46 | do { | |
| 47 | let matches = try await client.readList( | |
| 48 | ["repo", "grep", repoPath, trimmed], of: Match.self | |
| 49 | ) | |
| 50 | state = matches.isEmpty | |
| 51 | ? .empty("No matches for \"\(trimmed)\".") | |
| 52 | : .loaded(matches) | |
| 53 | } catch { | |
| 54 | state = .from(error) | |
| 55 | } | |
| 56 | } | |
| 57 | } | |
gitbay/Discovery/ProfileViewModel.swift added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `profile show <name>` plus the profile's reachable repositories via | |
| 5 | /// `repo search` filtered to the owner prefix. | |
| 6 | @Observable | |
| 7 | @MainActor | |
| 8 | final class ProfileViewModel { | |
| 9 | ||
| 10 | nonisolated struct Profile: Decodable, Sendable, Hashable { | |
| 11 | let name: String | |
| 12 | let kind: String // user | org | |
| 13 | let description: String? | |
| 14 | let website: String? | |
| 15 | } | |
| 16 | ||
| 17 | private(set) var state: LoadState<Profile> = .loading | |
| 18 | private(set) var repos: [RepoSummary] = [] | |
| 19 | ||
| 20 | private let client: GitbayClient | |
| 21 | let name: String | |
| 22 | ||
| 23 | init(client: GitbayClient, name: String) { | |
| 24 | self.client = client | |
| 25 | self.name = name | |
| 26 | } | |
| 27 | ||
| 28 | func load() async { | |
| 29 | do { | |
| 30 | state = .loaded(try await client.read(["profile", "show", name], as: Profile.self)) | |
| 31 | } catch { | |
| 32 | state = .from(error) | |
| 33 | return | |
| 34 | } | |
| 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 | } | |
| 40 | } | |
gitbay/Repos/RepoListViewModel.swift +38 −7
| @@ -1,17 +1,29 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import Observation |
| 3 | 3 | |
| 4 | /// The `repo list` screen, paginated, filterable client-side. The filter | |
| 5 | /// only sees loaded pages; the page size is large enough that one page | |
| 6 | /// covers most accounts today. | |
| 4 | /// The `repo list` screen, paginated. A typed query searches server-side | |
| 5 | /// (`repo search` covers name, description and topics across everything | |
| 6 | /// reachable — including repos not in your list), with the loaded pages | |
| 7 | /// filtered client-side while the search is in flight. | |
| 7 | 8 | @Observable |
| 8 | 9 | @MainActor |
| 9 | 10 | final class RepoListViewModel { |
| 10 | 11 | |
| 11 | 12 | let list: PagedListModel<RepoSummary> |
| 12 | var searchText = "" | |
| 13 | var searchText = "" { | |
| 14 | didSet { | |
| 15 | guard searchText != oldValue else { return } | |
| 16 | scheduleSearch() | |
| 17 | } | |
| 18 | } | |
| 19 | /// Server results for the current query; nil while none has landed. | |
| 20 | private(set) var searchResults: [RepoSummary]? | |
| 21 | ||
| 22 | private let client: GitbayClient | |
| 23 | private var searchTask: Task<Void, Never>? | |
| 13 | 24 | |
| 14 | 25 | init(client: GitbayClient) { |
| 26 | self.client = client | |
| 15 | 27 | list = PagedListModel( |
| 16 | 28 | client: client, |
| 17 | 29 | argv: ["repo", "list"], |
| @@ -23,10 +35,13 @@ final class RepoListViewModel { | ||
| 23 | 35 | var state: LoadState<[RepoSummary]> { list.state } |
| 24 | 36 | |
| 25 | 37 | var visibleRepos: [RepoSummary] { |
| 26 | guard let repos = list.state.value else { return [] } | |
| 27 | 38 | let query = searchText.trimmingCharacters(in: .whitespaces).lowercased() |
| 28 | guard !query.isEmpty else { return repos } | |
| 29 | return repos.filter { | |
| 39 | guard !query.isEmpty else { return list.state.value ?? [] } | |
| 40 | if let searchResults { | |
| 41 | return searchResults | |
| 42 | } | |
| 43 | // The server search has not answered yet; filter what is loaded. | |
| 44 | return (list.state.value ?? []).filter { | |
| 30 | 45 | $0.path.lowercased().contains(query) |
| 31 | 46 | || ($0.description ?? "").lowercased().contains(query) |
| 32 | 47 | } |
| @@ -35,4 +50,20 @@ final class RepoListViewModel { | ||
| 35 | 50 | func load() async { |
| 36 | 51 | await list.reload() |
| 37 | 52 | } |
| 53 | ||
| 54 | private func scheduleSearch() { | |
| 55 | searchTask?.cancel() | |
| 56 | searchResults = nil | |
| 57 | let query = searchText.trimmingCharacters(in: .whitespaces) | |
| 58 | guard !query.isEmpty else { return } | |
| 59 | searchTask = Task { [client] in | |
| 60 | // Debounce a person typing; a cancelled sleep throws. | |
| 61 | guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return } | |
| 62 | let results = try? await client.readList( | |
| 63 | ["repo", "search", query], of: RepoSummary.self | |
| 64 | ) | |
| 65 | guard !Task.isCancelled, let results else { return } | |
| 66 | self.searchResults = results | |
| 67 | } | |
| 68 | } | |
| 38 | 69 | } |
gitbay/Repos/RepoModels.swift +2
| @@ -6,6 +6,8 @@ nonisolated struct RepoSummary: Decodable, Sendable, Hashable, Identifiable { | ||
| 6 | 6 | let visibility: String |
| 7 | 7 | let description: String? |
| 8 | 8 | let archived: Bool? |
| 9 | /// Present on `repo search` rows, absent on `repo list`. | |
| 10 | let topics: [String]? | |
| 9 | 11 | |
| 10 | 12 | var id: String { path } |
| 11 | 13 | var isArchived: Bool { archived ?? false } |
gitbay/Views/Discovery/FeedView.swift added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Activity on everything you can reach — the `feed` command, paginated. | |
| 4 | struct FeedView: View { | |
| 5 | ||
| 6 | @State private var list: PagedListModel<FeedEvent> | |
| 7 | ||
| 8 | init(client: GitbayClient) { | |
| 9 | _list = State(initialValue: PagedListModel( | |
| 10 | client: client, | |
| 11 | argv: ["feed"], | |
| 12 | emptyMessage: "Nothing has happened yet on repositories you can reach." | |
| 13 | )) | |
| 14 | } | |
| 15 | ||
| 16 | var body: some View { | |
| 17 | List { | |
| 18 | ForEach(list.state.value ?? []) { event in | |
| 19 | link(for: event) | |
| 20 | } | |
| 21 | PageFooter(list: list) | |
| 22 | } | |
| 23 | .overlay { LoadStateOverlay(state: list.state) } | |
| 24 | .navigationTitle("Feed") | |
| 25 | .toolbar { AccountMenu() } | |
| 26 | .task { | |
| 27 | if list.state.value == nil { | |
| 28 | await list.reload() | |
| 29 | } | |
| 30 | } | |
| 31 | .refreshable { await list.reload() } | |
| 32 | } | |
| 33 | ||
| 34 | @ViewBuilder | |
| 35 | private func link(for event: FeedEvent) -> some View { | |
| 36 | switch event.destination { | |
| 37 | case .repo(let repo): | |
| 38 | NavigationLink(value: RepoRoute.repo(repo)) { FeedRow(event: event) } | |
| 39 | case .mr(let repo, let number): | |
| 40 | NavigationLink(value: MRRoute.mr(repo: repo, number: number)) { FeedRow(event: event) } | |
| 41 | case .issue(let repo, let number): | |
| 42 | NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) { FeedRow(event: event) } | |
| 43 | case .build(let repo, let number): | |
| 44 | NavigationLink(value: BuildRoute.log(repo: repo, number: number)) { FeedRow(event: event) } | |
| 45 | case nil: | |
| 46 | FeedRow(event: event) | |
| 47 | } | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | private struct FeedRow: View { | |
| 52 | let event: FeedEvent | |
| 53 | ||
| 54 | var body: some View { | |
| 55 | HStack(spacing: 10) { | |
| 56 | Image(systemName: event.icon.name) | |
| 57 | .foregroundStyle(color) | |
| 58 | .frame(width: 22) | |
| 59 | VStack(alignment: .leading, spacing: 2) { | |
| 60 | Text(event.repo) | |
| 61 | .font(.caption) | |
| 62 | .foregroundStyle(.secondary) | |
| 63 | HStack(spacing: 4) { | |
| 64 | if let actor = event.actor, !actor.isEmpty { | |
| 65 | Text(actor) | |
| 66 | .font(.subheadline.weight(.semibold)) | |
| 67 | } | |
| 68 | Text(event.phrase) | |
| 69 | .font(.subheadline) | |
| 70 | .lineLimit(1) | |
| 71 | } | |
| 72 | } | |
| 73 | Spacer() | |
| 74 | Text(event.createdAt, format: .relative(presentation: .named)) | |
| 75 | .font(.caption) | |
| 76 | .foregroundStyle(.tertiary) | |
| 77 | } | |
| 78 | .padding(.vertical, 2) | |
| 79 | } | |
| 80 | ||
| 81 | private var color: Color { | |
| 82 | switch event.icon.isPositive { | |
| 83 | case true: .green | |
| 84 | case false: .red | |
| 85 | default: .secondary | |
| 86 | } | |
| 87 | } | |
| 88 | } | |
gitbay/Views/Discovery/GrepView.swift added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Server-side git grep over a repository's files. | |
| 4 | struct GrepView: View { | |
| 5 | ||
| 6 | @State private var model: GrepViewModel | |
| 7 | @State private var query = "" | |
| 8 | ||
| 9 | init(client: GitbayClient, repo: String) { | |
| 10 | _model = State(initialValue: GrepViewModel(client: client, repoPath: repo)) | |
| 11 | } | |
| 12 | ||
| 13 | var body: some View { | |
| 14 | List { | |
| 15 | ForEach(model.byFile, id: \.file) { group in | |
| 16 | Section { | |
| 17 | ForEach(group.matches) { match in | |
| 18 | NavigationLink(value: RepoRoute.file( | |
| 19 | repo: model.repoPath, path: match.path, ref: nil | |
| 20 | )) { | |
| 21 | HStack(alignment: .firstTextBaseline, spacing: 8) { | |
| 22 | Text(String(match.line)) | |
| 23 | .font(.caption.monospaced()) | |
| 24 | .foregroundStyle(.tertiary) | |
| 25 | .frame(minWidth: 32, alignment: .trailing) | |
| 26 | Text(match.text.trimmingCharacters(in: .whitespaces)) | |
| 27 | .font(.caption.monospaced()) | |
| 28 | .lineLimit(2) | |
| 29 | } | |
| 30 | } | |
| 31 | } | |
| 32 | } header: { | |
| 33 | Text(group.file) | |
| 34 | .font(.caption.monospaced()) | |
| 35 | .textCase(nil) | |
| 36 | } | |
| 37 | } | |
| 38 | } | |
| 39 | .overlay { | |
| 40 | if let state = model.state { | |
| 41 | LoadStateOverlay(state: state) | |
| 42 | } else { | |
| 43 | ContentUnavailableView { | |
| 44 | Label("Search in files", systemImage: "text.magnifyingglass") | |
| 45 | } description: { | |
| 46 | Text("Literal, case-insensitive, over the default branch — git grep, server-side.") | |
| 47 | } | |
| 48 | } | |
| 49 | } | |
| 50 | .searchable(text: $query, prompt: "Search file contents") | |
| 51 | .onSubmit(of: .search) { | |
| 52 | Task { await model.search(query) } | |
| 53 | } | |
| 54 | .navigationTitle("Grep") | |
| 55 | .navigationBarTitleDisplayMode(.inline) | |
| 56 | } | |
| 57 | } | |
gitbay/Views/Discovery/ProfileView.swift added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// A user or organization: `profile show` plus their reachable repos. | |
| 4 | struct ProfileView: View { | |
| 5 | ||
| 6 | @State private var model: ProfileViewModel | |
| 7 | ||
| 8 | init(client: GitbayClient, name: String) { | |
| 9 | _model = State(initialValue: ProfileViewModel(client: client, name: name)) | |
| 10 | } | |
| 11 | ||
| 12 | var body: some View { | |
| 13 | List { | |
| 14 | 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(.title2) | |
| 21 | .foregroundStyle(.secondary) | |
| 22 | Text(profile.name) | |
| 23 | .font(.title3.weight(.semibold)) | |
| 24 | Text(profile.kind) | |
| 25 | .font(.caption2) | |
| 26 | .padding(.horizontal, 6) | |
| 27 | .padding(.vertical, 1) | |
| 28 | .background(.quaternary, in: Capsule()) | |
| 29 | } | |
| 30 | if let description = profile.description, !description.isEmpty { | |
| 31 | Text(description) | |
| 32 | .font(.subheadline) | |
| 33 | .foregroundStyle(.secondary) | |
| 34 | } | |
| 35 | if let website = profile.website, let url = URL(string: website) { | |
| 36 | Link(website, destination: url) | |
| 37 | .font(.caption) | |
| 38 | .lineLimit(1) | |
| 39 | } | |
| 40 | } | |
| 41 | .padding(.vertical, 4) | |
| 42 | } | |
| 43 | ||
| 44 | if !model.repos.isEmpty { | |
| 45 | Section("Repositories") { | |
| 46 | ForEach(model.repos) { repo in | |
| 47 | NavigationLink(value: RepoRoute.repo(repo.path)) { | |
| 48 | VStack(alignment: .leading, spacing: 2) { | |
| 49 | Text(repo.name) | |
| 50 | .font(.subheadline.weight(.medium)) | |
| 51 | if let description = repo.description, !description.isEmpty { | |
| 52 | Text(description) | |
| 53 | .font(.caption) | |
| 54 | .foregroundStyle(.secondary) | |
| 55 | .lineLimit(1) | |
| 56 | } | |
| 57 | } | |
| 58 | } | |
| 59 | } | |
| 60 | } | |
| 61 | } | |
| 62 | } | |
| 63 | } | |
| 64 | .overlay { LoadStateOverlay(state: model.state) } | |
| 65 | .navigationTitle(model.name) | |
| 66 | .navigationBarTitleDisplayMode(.inline) | |
| 67 | .task { await model.load() } | |
| 68 | .refreshable { await model.load() } | |
| 69 | } | |
| 70 | } | |
gitbay/Views/Repos/RepoRoute.swift +2
| @@ -8,5 +8,7 @@ nonisolated enum RepoRoute: Hashable { | ||
| 8 | 8 | case file(repo: String, path: String, ref: String?) |
| 9 | 9 | case log(repo: String) |
| 10 | 10 | case settings(repo: String) |
| 11 | case grep(repo: String) | |
| 12 | case profile(String) | |
| 11 | 13 | case addAccount |
| 12 | 14 | } |
gitbay/Views/Repos/RepoView.swift +6
| @@ -40,9 +40,15 @@ struct RepoView: View { | ||
| 40 | 40 | NavigationLink(value: BuildRoute.list(repo: path)) { |
| 41 | 41 | Label("Builds", systemImage: "hammer") |
| 42 | 42 | } |
| 43 | NavigationLink(value: RepoRoute.grep(repo: path)) { | |
| 44 | Label("Search in Files", systemImage: "text.magnifyingglass") | |
| 45 | } | |
| 43 | 46 | NavigationLink(value: RepoRoute.settings(repo: path)) { |
| 44 | 47 | Label("Settings", systemImage: "gearshape") |
| 45 | 48 | } |
| 49 | NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) { | |
| 50 | Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle") | |
| 51 | } | |
| 46 | 52 | } |
| 47 | 53 | |
| 48 | 54 | if let readme = model.readme { |
gitbayTests/DiscoveryTests.swift added +201
| @@ -0,0 +1,201 @@ | ||
| 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 | struct FeedEventTests { | |
| 16 | ||
| 17 | private func decode(_ json: String) throws -> FeedEvent { | |
| 18 | let decoder = JSONDecoder() | |
| 19 | decoder.dateDecodingStrategy = .iso8601 | |
| 20 | return try decoder.decode(FeedEvent.self, from: Data(json.utf8)) | |
| 21 | } | |
| 22 | ||
| 23 | @Test func knownKindsGetPhrasesAndDestinations() throws { | |
| 24 | let merged = try decode(""" | |
| 25 | {"id":777,"repo":"krz/gitbay-ios","actor":"cmc","kind":"mr.merged",\ | |
| 26 | "data":{"number":6,"sha":"b55fbb"},"created_at":"2026-08-27T15:31:10Z"} | |
| 27 | """) | |
| 28 | #expect(merged.phrase == "merged !6") | |
| 29 | #expect(merged.destination == .mr(repo: "krz/gitbay-ios", number: 6)) | |
| 30 | ||
| 31 | let build = try decode(""" | |
| 32 | {"id":1,"repo":"krz/gitbay","actor":"","kind":"build.success",\ | |
| 33 | "data":{"number":56},"created_at":"2026-08-27T15:31:10Z"} | |
| 34 | """) | |
| 35 | #expect(build.phrase == "build #56 succeeded") | |
| 36 | #expect(build.destination == .build(repo: "krz/gitbay", number: 56)) | |
| 37 | ||
| 38 | let release = try decode(""" | |
| 39 | {"id":2,"repo":"krz/orgo","actor":"krz","kind":"release.created",\ | |
| 40 | "data":{"tag":"v2.1.0"},"created_at":"2026-08-27T15:31:10Z"} | |
| 41 | """) | |
| 42 | #expect(release.phrase == "released v2.1.0") | |
| 43 | #expect(release.destination == .repo("krz/orgo")) | |
| 44 | } | |
| 45 | ||
| 46 | @Test func unknownKindsRenderRawAndFallBackToTheRepo() throws { | |
| 47 | let event = try decode(""" | |
| 48 | {"id":3,"repo":"krz/gitbay","kind":"wiki.edited",\ | |
| 49 | "data":{"page":"Parity"},"created_at":"2026-08-27T15:31:10Z"} | |
| 50 | """) | |
| 51 | #expect(event.phrase == "wiki.edited") | |
| 52 | #expect(event.destination == .repo("krz/gitbay")) | |
| 53 | } | |
| 54 | ||
| 55 | @Test func missingDataDoesNotSinkTheEvent() throws { | |
| 56 | let event = try decode(""" | |
| 57 | {"id":4,"repo":"krz/gitbay","actor":"cmc","kind":"push",\ | |
| 58 | "created_at":"2026-08-27T15:31:10Z"} | |
| 59 | """) | |
| 60 | #expect(event.phrase == "pushed") | |
| 61 | #expect(event.destination == .repo("krz/gitbay")) | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | @MainActor | |
| 66 | struct GrepViewModelTests { | |
| 67 | ||
| 68 | @Test func searchGroupsMatchesByFileInServerOrder() async throws { | |
| 69 | let (client, stub) = try makeClient() | |
| 70 | stub.enqueue(.init(status: 200, json: """ | |
| 71 | {"protocol_version":1,"data":[\ | |
| 72 | {"path":"internal/a.go","line":3,"text":"foo bar"},\ | |
| 73 | {"path":"internal/a.go","line":9,"text":"more foo"},\ | |
| 74 | {"path":"cmd/b.go","line":1,"text":"foo again"}\ | |
| 75 | ],"exit_code":0} | |
| 76 | """)) | |
| 77 | let model = GrepViewModel(client: client, repoPath: "krz/gitbay") | |
| 78 | ||
| 79 | await model.search(" foo ") | |
| 80 | ||
| 81 | #expect(model.byFile.map(\.file) == ["internal/a.go", "cmd/b.go"]) | |
| 82 | #expect(model.byFile[0].matches.map(\.line) == [3, 9]) | |
| 83 | let seen = try #require(stub.seen.first) | |
| 84 | #expect(seen.url.query() == "argv=repo&argv=grep&argv=krz/gitbay&argv=foo") | |
| 85 | } | |
| 86 | ||
| 87 | @Test func noMatchesIsAnEmptyStateNamingTheQuery() async throws { | |
| 88 | let (client, stub) = try makeClient() | |
| 89 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#)) | |
| 90 | let model = GrepViewModel(client: client, repoPath: "krz/gitbay") | |
| 91 | ||
| 92 | await model.search("nothing") | |
| 93 | ||
| 94 | guard case .empty(let message)? = model.state else { | |
| 95 | Issue.record("expected .empty, got \(String(describing: model.state))") | |
| 96 | return | |
| 97 | } | |
| 98 | #expect(message.contains("nothing")) | |
| 99 | } | |
| 100 | ||
| 101 | @Test func aBlankQueryDoesNotSearch() async throws { | |
| 102 | let (client, stub) = try makeClient() | |
| 103 | _ = client | |
| 104 | let model = GrepViewModel(client: client, repoPath: "krz/gitbay") | |
| 105 | ||
| 106 | await model.search(" ") | |
| 107 | ||
| 108 | #expect(model.state == nil) | |
| 109 | #expect(stub.seen.isEmpty) | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | @MainActor | |
| 114 | struct ProfileViewModelTests { | |
| 115 | ||
| 116 | @Test func loadsProfileAndPrefixFilteredRepos() async throws { | |
| 117 | let (client, stub) = try makeClient() | |
| 118 | stub.enqueue(.init(status: 200, json: """ | |
| 119 | {"protocol_version":1,"data":{"name":"krz","kind":"org",\ | |
| 120 | "description":"warez for the public","website":"https://krz.sh"},"exit_code":0} | |
| 121 | """, match: "argv=profile")) | |
| 122 | stub.enqueue(.init(status: 200, json: """ | |
| 123 | {"protocol_version":1,"data":[\ | |
| 124 | {"path":"krz/gitbay","visibility":"public","topics":["git"]},\ | |
| 125 | {"path":"cmc/krz-notes","visibility":"public"}\ | |
| 126 | ],"exit_code":0} | |
| 127 | """, match: "argv=search")) | |
| 128 | let model = ProfileViewModel(client: client, name: "krz") | |
| 129 | ||
| 130 | await model.load() | |
| 131 | ||
| 132 | let profile = try #require(model.state.value) | |
| 133 | #expect(profile.kind == "org") | |
| 134 | // Only true owner-prefix matches; "cmc/krz-notes" merely mentions it. | |
| 135 | #expect(model.repos.map(\.path) == ["krz/gitbay"]) | |
| 136 | } | |
| 137 | ||
| 138 | @Test func anUnknownNameIsAnEmptyState() async throws { | |
| 139 | let (client, stub) = try makeClient() | |
| 140 | stub.enqueue(.init(status: 404, json: | |
| 141 | #"{"protocol_version":1,"error":"no such user or org \"nobody\"","exit_code":3}"#, | |
| 142 | match: "argv=profile")) | |
| 143 | let model = ProfileViewModel(client: client, name: "nobody") | |
| 144 | ||
| 145 | await model.load() | |
| 146 | ||
| 147 | guard case .empty = model.state else { | |
| 148 | Issue.record("expected .empty, got \(model.state)") | |
| 149 | return | |
| 150 | } | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | @MainActor | |
| 155 | struct RepoSearchTests { | |
| 156 | ||
| 157 | @Test func aTypedQuerySearchesServerSideAfterTheDebounce() async throws { | |
| 158 | let (client, stub) = try makeClient() | |
| 159 | stub.enqueue(.init(status: 200, json: """ | |
| 160 | {"protocol_version":1,"data":{"items":[\ | |
| 161 | {"path":"cmc/notes","visibility":"private"}]},"exit_code":0} | |
| 162 | """, match: "argv=repo&argv=list")) | |
| 163 | stub.enqueue(.init(status: 200, json: """ | |
| 164 | {"protocol_version":1,"data":[\ | |
| 165 | {"path":"krz/space-wiki","visibility":"public","description":"A fun wiki about space.",\ | |
| 166 | "topics":["wiki"]}],"exit_code":0} | |
| 167 | """, match: "argv=search")) | |
| 168 | let model = RepoListViewModel(client: client) | |
| 169 | await model.load() | |
| 170 | ||
| 171 | model.searchText = "space" | |
| 172 | // Before the server answers, the client filter runs over loaded | |
| 173 | // pages — no match here. | |
| 174 | #expect(model.visibleRepos.isEmpty) | |
| 175 | try await Task.sleep(for: .milliseconds(700)) | |
| 176 | ||
| 177 | // The server search found a public repo not in the account's list. | |
| 178 | #expect(model.visibleRepos.map(\.path) == ["krz/space-wiki"]) | |
| 179 | #expect(stub.seen.contains { | |
| 180 | $0.url.query() == "argv=repo&argv=search&argv=space" | |
| 181 | }) | |
| 182 | } | |
| 183 | ||
| 184 | @Test func clearingTheQueryRestoresThePagedList() async throws { | |
| 185 | let (client, stub) = try makeClient() | |
| 186 | stub.enqueue(.init(status: 200, json: """ | |
| 187 | {"protocol_version":1,"data":{"items":[\ | |
| 188 | {"path":"cmc/notes","visibility":"private"}]},"exit_code":0} | |
| 189 | """, match: "argv=repo&argv=list")) | |
| 190 | let model = RepoListViewModel(client: client) | |
| 191 | await model.load() | |
| 192 | ||
| 193 | model.searchText = "zzz" | |
| 194 | model.searchText = "" | |
| 195 | try await Task.sleep(for: .milliseconds(500)) | |
| 196 | ||
| 197 | #expect(model.visibleRepos.map(\.path) == ["cmc/notes"]) | |
| 198 | // The emptied query never reached the server. | |
| 199 | #expect(!stub.seen.contains { ($0.url.query() ?? "").contains("argv=search") }) | |
| 200 | } | |
| 201 | } | |
gitbayUITests/LiveSmokeUITests.swift +52
| @@ -256,3 +256,55 @@ extension LiveSmokeUITests { | ||
| 256 | 256 | return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed |
| 257 | 257 | } |
| 258 | 258 | } |
| 259 | ||
| 260 | extension LiveSmokeUITests { | |
| 261 | ||
| 262 | /// Discovery is read-only: feed, server-side repo search, grep, and | |
| 263 | /// profiles. No cleanup needed. | |
| 264 | func testDiscoveryFlows() throws { | |
| 265 | // --- feed renders events and navigates --- | |
| 266 | let feedTab = app.buttons["Feed"].firstMatch | |
| 267 | XCTAssertTrue(feedTab.waitForExistence(timeout: 10)) | |
| 268 | feedTab.tap() | |
| 269 | let firstEvent = app.cells.firstMatch | |
| 270 | XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events") | |
| 271 | firstEvent.tap() | |
| 272 | // Wherever the event led, it left the feed root behind. | |
| 273 | XCTAssertTrue(app.navigationBars.buttons.firstMatch | |
| 274 | .waitForExistence(timeout: 10), "feed row did not navigate") | |
| 275 | back() | |
| 276 | ||
| 277 | // --- server-side search: "astronomy" is only a topic, invisible | |
| 278 | // to the client-side path/description filter --- | |
| 279 | let repoTab = app.buttons["Repositories"].firstMatch | |
| 280 | repoTab.tap() | |
| 281 | let search = app.searchFields.firstMatch | |
| 282 | XCTAssertTrue(search.waitForExistence(timeout: 10)) | |
| 283 | search.tap() | |
| 284 | search.typeText("astronomy") | |
| 285 | let hit = app.staticTexts["krz/space-wiki"].firstMatch | |
| 286 | XCTAssertTrue(hit.waitForExistence(timeout: 15), | |
| 287 | "server-side topic search found nothing") | |
| 288 | hit.tap() | |
| 289 | XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10)) | |
| 290 | ||
| 291 | // --- owner profile from the repo screen --- | |
| 292 | // The row's label merges; match it at any element type. | |
| 293 | app.descendants(matching: .any)["krz"].firstMatch.tap() | |
| 294 | XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch | |
| 295 | .waitForExistence(timeout: 15), "profile did not load") | |
| 296 | XCTAssertTrue(app.staticTexts["Repositories"].firstMatch.exists, | |
| 297 | "profile repos missing") | |
| 298 | back() | |
| 299 | ||
| 300 | // --- grep inside the repo, last: its search UI owns the screen --- | |
| 301 | app.staticTexts["Search in Files"].firstMatch.tap() | |
| 302 | let grepField = app.searchFields.firstMatch | |
| 303 | XCTAssertTrue(grepField.waitForExistence(timeout: 10)) | |
| 304 | grepField.tap() | |
| 305 | grepField.typeText("space") | |
| 306 | app.keyboards.buttons["search"].firstMatch.tap() | |
| 307 | let match = app.cells.firstMatch | |
| 308 | XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches") | |
| 309 | } | |
| 310 | } | |