Commit 4c43ef244d
Verified · cmc
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 | |||
| 5 | struct ExploreView: View { | 5 | struct 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. | ||
| 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 | } |