Commit 4c43ef244d
Verified · cmc
Layout: unified · split
gitbay/Discovery/SearchViewModel.swift +1
| @@ -61,6 +61,7 @@ final class SearchViewModel { | ||
| 61 | 61 | didSet { |
| 62 | 62 | guard kind != oldValue, isSearching else { return } |
| 63 | 63 | searchTask?.cancel() |
| 64 | state = .loading | |
| 64 | 65 | searchTask = Task { await run() } |
| 65 | 66 | } |
| 66 | 67 | } |
gitbay/Views/Discovery/ExploreView.swift +137 −24
| @@ -5,6 +5,7 @@ import SwiftUI | ||
| 5 | 5 | struct ExploreView: View { |
| 6 | 6 | |
| 7 | 7 | @State private var list: PagedListModel<PublicRepo> |
| 8 | @State private var search: SearchViewModel | |
| 8 | 9 | |
| 9 | 10 | init(client: GitbayClient) { |
| 10 | 11 | _list = State(initialValue: PagedListModel( |
| @@ -12,6 +13,7 @@ struct ExploreView: View { | ||
| 12 | 13 | argv: ["explore"], |
| 13 | 14 | emptyMessage: "This instance hosts no public repositories." |
| 14 | 15 | )) |
| 16 | _search = State(initialValue: SearchViewModel(client: client)) | |
| 15 | 17 | } |
| 16 | 18 | |
| 17 | 19 | /// One Text, not two: the HStack spaces its children, and a gap |
| @@ -24,46 +26,157 @@ struct ExploreView: View { | ||
| 24 | 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 | 42 | var body: some View { |
| 28 | 43 | 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 | } | |
| 36 | 66 | } |
| 37 | } | |
| 38 | .font(.gbSans(.subheadline)) | |
| 39 | .lineLimit(1) | |
| 67 | .font(.gbSans(.subheadline)) | |
| 68 | .lineLimit(1) | |
| 40 | 69 | |
| 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 | } | |
| 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 | 100 | .navigationTitle("Explore") |
| 64 | 101 | .task { |
| 65 | 102 | if list.state.value == nil { await list.reload() } |
| 66 | 103 | } |
| 67 | 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. | |
| 141 | private 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 | } |