Commit 4c43ef244d

4c43ef244de53129489e747c8cc08a7fa9033473

parent: ec160366ca

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 20:02 UTC

Search the instance from the Explore tab

Layout: unified · split

gitbay/Discovery/SearchViewModel.swift +1
@@ -61,6 +61,7 @@ final class SearchViewModel {
61 didSet { 61 didSet {
62 guard kind != oldValue, isSearching else { return } 62 guard kind != oldValue, isSearching else { return }
63 searchTask?.cancel() 63 searchTask?.cancel()
64 state = .loading
64 searchTask = Task { await run() } 65 searchTask = Task { await run() }
65 } 66 }
66 } 67 }
gitbay/Views/Discovery/ExploreView.swift +137 −24
@@ -5,6 +5,7 @@ import SwiftUI
5struct ExploreView: View { 5struct ExploreView: View {
6 6
7 @State private var list: PagedListModel<PublicRepo> 7 @State private var list: PagedListModel<PublicRepo>
8 @State private var search: SearchViewModel
8 9
9 init(client: GitbayClient) { 10 init(client: GitbayClient) {
10 _list = State(initialValue: PagedListModel( 11 _list = State(initialValue: PagedListModel(
@@ -12,6 +13,7 @@ struct ExploreView: View {
12 argv: ["explore"], 13 argv: ["explore"],
13 emptyMessage: "This instance hosts no public repositories." 14 emptyMessage: "This instance hosts no public repositories."
14 )) 15 ))
16 _search = State(initialValue: SearchViewModel(client: client))
15 } 17 }
16 18
17 /// One Text, not two: the HStack spaces its children, and a gap 19 /// One Text, not two: the HStack spaces its children, and a gap
@@ -24,46 +26,157 @@ struct ExploreView: View {
24 return owner + name 26 return owner + name
25 } 27 }
26 28
29 /// Same styling as `path(of:)`, but for a search result's already
30 /// joined "owner/name" string.
31 private func styledPath(_ path: String) -> AttributedString {
32 guard let slash = path.firstIndex(of: "/") else {
33 return AttributedString(path)
34 }
35 var owner = AttributedString(String(path[...slash]))
36 owner.foregroundColor = .secondary
37 var name = AttributedString(String(path[path.index(after: slash)...]))
38 name.font = .gbSans(.subheadline).weight(.medium)
39 return owner + name
40 }
41
27 var body: some View { 42 var body: some View {
28 List { 43 List {
29 ForEach(list.state.value ?? []) { repo in 44 if search.isSearching {
30 NavigationLink(value: RepoRoute.repo(repo.path)) { 45 Picker("Kind", selection: Bindable(search).kind) {
31 VStack(alignment: .leading, spacing: 4) { 46 ForEach(SearchKind.allCases) { kind in
32 HStack(spacing: 6) { 47 Text(kind.label).tag(kind)
33 Text(path(of: repo)) 48 }
34 if repo.isArchived { 49 }
35 GBChip("archived", .secondary) 50 .pickerStyle(.segmented)
51 .listRowBackground(Color.clear)
52 .listRowInsets(EdgeInsets())
53
54 ForEach(search.state.value ?? []) { result in
55 searchRow(for: result)
56 }
57 } else {
58 ForEach(list.state.value ?? []) { repo in
59 NavigationLink(value: RepoRoute.repo(repo.path)) {
60 VStack(alignment: .leading, spacing: 4) {
61 HStack(spacing: 6) {
62 Text(path(of: repo))
63 if repo.isArchived {
64 GBChip("archived", .secondary)
65 }
36 } 66 }
37 } 67 .font(.gbSans(.subheadline))
38 .font(.gbSans(.subheadline)) 68 .lineLimit(1)
39 .lineLimit(1)
40 69
41 if let description = repo.description, !description.isEmpty { 70 if let description = repo.description, !description.isEmpty {
42 Text(description) 71 Text(description)
43 .font(.gbSans(.caption)) 72 .font(.gbSans(.caption))
44 .foregroundStyle(.secondary) 73 .foregroundStyle(.secondary)
45 .lineLimit(2) 74 .lineLimit(2)
46 } 75 }
47 if let topics = repo.topics, !topics.isEmpty { 76 if let topics = repo.topics, !topics.isEmpty {
48 ScrollView(.horizontal, showsIndicators: false) { 77 ScrollView(.horizontal, showsIndicators: false) {
49 HStack(spacing: 6) { 78 HStack(spacing: 6) {
50 ForEach(topics, id: \.self) { topic in 79 ForEach(topics, id: \.self) { topic in
51 GBChip(topic, .gbAccent) 80 GBChip(topic, .gbAccent)
81 }
52 } 82 }
53 } 83 }
54 } 84 }
55 } 85 }
86 .padding(.vertical, 2)
56 } 87 }
57 .padding(.vertical, 2)
58 } 88 }
89 PageFooter(list: list)
59 } 90 }
60 PageFooter(list: list)
61 } 91 }
62 .overlay { LoadStateOverlay(state: list.state) } 92 .overlay {
93 if search.isSearching {
94 LoadStateOverlay(state: search.state)
95 } else {
96 LoadStateOverlay(state: list.state)
97 }
98 }
99 .searchable(text: Bindable(search).query, prompt: "Search this instance")
63 .navigationTitle("Explore") 100 .navigationTitle("Explore")
64 .task { 101 .task {
65 if list.state.value == nil { await list.reload() } 102 if list.state.value == nil { await list.reload() }
66 } 103 }
67 .refreshable { await list.reload() } 104 .refreshable { await list.reload() }
68 } 105 }
106
107 @ViewBuilder
108 private func searchRow(for result: SearchResult) -> some View {
109 switch result.kind {
110 case "repo":
111 NavigationLink(value: RepoRoute.repo(result.repo)) {
112 Text(styledPath(result.repo))
113 .font(.gbSans(.subheadline))
114 .lineLimit(1)
115 .padding(.vertical, 2)
116 }
117 case "issue":
118 if let number = result.number {
119 NavigationLink(value: IssueRoute.issue(repo: result.repo, number: number)) {
120 SearchEntryRow(result: result)
121 }
122 } else {
123 SearchEntryRow(result: result)
124 }
125 case "mr":
126 if let number = result.number {
127 NavigationLink(value: MRRoute.mr(repo: result.repo, number: number)) {
128 SearchEntryRow(result: result)
129 }
130 } else {
131 SearchEntryRow(result: result)
132 }
133 default:
134 SearchEntryRow(result: result)
135 }
136 }
137}
138
139/// An issue or merge request row from `search` — `#` for an issue,
140/// `!` for a merge request, this forge's convention for the two.
141private struct SearchEntryRow: View {
142 let result: SearchResult
143
144 var body: some View {
145 VStack(alignment: .leading, spacing: 4) {
146 Text(result.repo)
147 .font(.gbSans(.caption))
148 .foregroundStyle(.secondary)
149 .lineLimit(1)
150 HStack(alignment: .firstTextBaseline, spacing: 6) {
151 if let number = result.number {
152 Text((result.kind == "issue" ? "#" : "!") + "\(number)")
153 .font(.gbMono(.caption))
154 .foregroundStyle(.secondary)
155 }
156 Text(result.title)
157 .font(.gbSans(.subheadline).weight(.medium))
158 .lineLimit(2)
159 }
160 HStack(spacing: 6) {
161 if let state = result.state {
162 GBChip(state, color(for: state))
163 }
164 Spacer()
165 if let updatedAt = result.updatedAt {
166 Text(updatedAt, format: .relative(presentation: .named))
167 .font(.gbSans(.caption))
168 .foregroundStyle(.secondary)
169 }
170 }
171 }
172 .padding(.vertical, 2)
173 }
174
175 private func color(for state: String) -> Color {
176 switch state {
177 case "open": .gbOK
178 case "merged": .gbDone
179 default: .gbBad
180 }
181 }
69} 182}