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 {
6161 didSet {
6262 guard kind != oldValue, isSearching else { return }
6363 searchTask?.cancel()
64 state = .loading
6465 searchTask = Task { await run() }
6566 }
6667 }
gitbay/Views/Discovery/ExploreView.swift +137 −24
@@ -5,6 +5,7 @@ import SwiftUI
55struct ExploreView: View {
66
77 @State private var list: PagedListModel<PublicRepo>
8 @State private var search: SearchViewModel
89
910 init(client: GitbayClient) {
1011 _list = State(initialValue: PagedListModel(
@@ -12,6 +13,7 @@ struct ExploreView: View {
1213 argv: ["explore"],
1314 emptyMessage: "This instance hosts no public repositories."
1415 ))
16 _search = State(initialValue: SearchViewModel(client: client))
1517 }
1618
1719 /// One Text, not two: the HStack spaces its children, and a gap
@@ -24,46 +26,157 @@ struct ExploreView: View {
2426 return owner + name
2527 }
2628
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
2742 var body: some View {
2843 List {
29 ForEach(list.state.value ?? []) { repo in
30 NavigationLink(value: RepoRoute.repo(repo.path)) {
31 VStack(alignment: .leading, spacing: 4) {
32 HStack(spacing: 6) {
33 Text(path(of: repo))
34 if repo.isArchived {
35 GBChip("archived", .secondary)
44 if search.isSearching {
45 Picker("Kind", selection: Bindable(search).kind) {
46 ForEach(SearchKind.allCases) { kind in
47 Text(kind.label).tag(kind)
48 }
49 }
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 }
3666 }
37 }
38 .font(.gbSans(.subheadline))
39 .lineLimit(1)
67 .font(.gbSans(.subheadline))
68 .lineLimit(1)
4069
41 if let description = repo.description, !description.isEmpty {
42 Text(description)
43 .font(.gbSans(.caption))
44 .foregroundStyle(.secondary)
45 .lineLimit(2)
46 }
47 if let topics = repo.topics, !topics.isEmpty {
48 ScrollView(.horizontal, showsIndicators: false) {
49 HStack(spacing: 6) {
50 ForEach(topics, id: \.self) { topic in
51 GBChip(topic, .gbAccent)
70 if let description = repo.description, !description.isEmpty {
71 Text(description)
72 .font(.gbSans(.caption))
73 .foregroundStyle(.secondary)
74 .lineLimit(2)
75 }
76 if let topics = repo.topics, !topics.isEmpty {
77 ScrollView(.horizontal, showsIndicators: false) {
78 HStack(spacing: 6) {
79 ForEach(topics, id: \.self) { topic in
80 GBChip(topic, .gbAccent)
81 }
5282 }
5383 }
5484 }
5585 }
86 .padding(.vertical, 2)
5687 }
57 .padding(.vertical, 2)
5888 }
89 PageFooter(list: list)
5990 }
60 PageFooter(list: list)
6191 }
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")
63100 .navigationTitle("Explore")
64101 .task {
65102 if list.state.value == nil { await list.reload() }
66103 }
67104 .refreshable { await list.reload() }
68105 }
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 }
69182}