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