a native ios client for gitbay

client ios swift

https://gitbay.org

Commit 2fe3160315

2fe3160315c8df0fe1c7d8968ee755f2d327f73d

parent: b55fbb39ba

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T15:42:30Z

discovery: feed, server-side search, grep, profiles

A Feed tab renders the paginated feed command: one row per event with
a phrase and destination mapped per kind (mr.*, issue.*, build.*,
release.created, push); unknown kinds render raw and fall back to the
repo, so new server kinds cannot sink the screen.

The repo list's search now queries repo search server-side (debounced
300ms), which covers name, description and topics across everything
reachable — including repos outside your list; the client-side filter
over loaded pages fills the gap while the search is in flight.

repo grep gets a screen per repository (results grouped by file, rows
open the file), and profile show gets one (user/org card plus their
reachable repos via a prefix-filtered search), reachable from the
repo screen's owner row.

Live UI smoke: testDiscoveryFlows (read-only) — feed navigation, a
topic-only search ("astronomy" -> krz/space-wiki, provable only
server-side), grep matches, and the org profile card.

Closes #3
Ref #11
gitbay/ContentView.swift +10
@@ -13,6 +13,12 @@ struct ContentView: View {
1313 .navigationDestinations(client: client)
1414 }
1515 }
16 Tab("Feed", systemImage: "bolt") {
17 NavigationStack {
18 FeedView(client: client)
19 .navigationDestinations(client: client)
20 }
21 }
1622 Tab("Repositories", systemImage: "books.vertical") {
1723 NavigationStack {
1824 RepoListView(client: client)
@@ -64,6 +70,10 @@ private struct RouteDestinations: ViewModifier {
6470 LogView(client: client, repo: repo)
6571 case .settings(let repo):
6672 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)
6777 case .addAccount:
6878 SignInView()
6979 }
gitbay/Discovery/FeedEvent.swift added +80
@@ -0,0 +1,80 @@
1import Foundation
2
3/// One `feed` event. `data` varies by kind; unknown kinds and fields
4/// must render, not sink the feed.
5nonisolated 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
75nonisolated 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 @@
1import Foundation
2import Observation
3
4/// `repo grep <owner/name> <query>` server-side git grep, literal and
5/// case-insensitive.
6@Observable
7@MainActor
8final 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 @@
1import Foundation
2import 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
8final 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 @@
11 import Foundation
22 import Observation
33
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.
78 @Observable
89 @MainActor
910 final class RepoListViewModel {
1011
1112 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>?
1324
1425 init(client: GitbayClient) {
26 self.client = client
1527 list = PagedListModel(
1628 client: client,
1729 argv: ["repo", "list"],
@@ -23,10 +35,13 @@ final class RepoListViewModel {
2335 var state: LoadState<[RepoSummary]> { list.state }
2436
2537 var visibleRepos: [RepoSummary] {
26 guard let repos = list.state.value else { return [] }
2738 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 {
3045 $0.path.lowercased().contains(query)
3146 || ($0.description ?? "").lowercased().contains(query)
3247 }
@@ -35,4 +50,20 @@ final class RepoListViewModel {
3550 func load() async {
3651 await list.reload()
3752 }
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 }
3869 }
gitbay/Repos/RepoModels.swift +2
@@ -6,6 +6,8 @@ nonisolated struct RepoSummary: Decodable, Sendable, Hashable, Identifiable {
66 let visibility: String
77 let description: String?
88 let archived: Bool?
9 /// Present on `repo search` rows, absent on `repo list`.
10 let topics: [String]?
911
1012 var id: String { path }
1113 var isArchived: Bool { archived ?? false }
gitbay/Views/Discovery/FeedView.swift added +88
@@ -0,0 +1,88 @@
1import SwiftUI
2
3/// Activity on everything you can reach the `feed` command, paginated.
4struct 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
51private 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 @@
1import SwiftUI
2
3/// Server-side git grep over a repository's files.
4struct 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 @@
1import SwiftUI
2
3/// A user or organization: `profile show` plus their reachable repos.
4struct 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 {
88 case file(repo: String, path: String, ref: String?)
99 case log(repo: String)
1010 case settings(repo: String)
11 case grep(repo: String)
12 case profile(String)
1113 case addAccount
1214 }
gitbay/Views/Repos/RepoView.swift +6
@@ -40,9 +40,15 @@ struct RepoView: View {
4040 NavigationLink(value: BuildRoute.list(repo: path)) {
4141 Label("Builds", systemImage: "hammer")
4242 }
43 NavigationLink(value: RepoRoute.grep(repo: path)) {
44 Label("Search in Files", systemImage: "text.magnifyingglass")
45 }
4346 NavigationLink(value: RepoRoute.settings(repo: path)) {
4447 Label("Settings", systemImage: "gearshape")
4548 }
49 NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) {
50 Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle")
51 }
4652 }
4753
4854 if let readme = model.readme {
gitbayTests/DiscoveryTests.swift added +201
@@ -0,0 +1,201 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private 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
15struct 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
66struct 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
114struct 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
155struct 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 {
256256 return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
257257 }
258258 }
259
260extension 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}