gitbay/Views/Discovery/ExploreView.swift
182 lines · 6850 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 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 }
66 }
67 .font(.gbSans(.subheadline))
68 .lineLimit(1)
69
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 }
82 }
83 }
84 }
85 }
86 .padding(.vertical, 2)
87 }
88 }
89 PageFooter(list: list)
90 }
91 }
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")
100 .navigationTitle("Explore")
101 .task {
102 if list.state.value == nil { await list.reload() }
103 }
104 .refreshable { await list.reload() }
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 }
182}