import SwiftUI /// What this instance hosts. `repo search` needs a query; this is the /// listing you read when you do not know a name yet. struct ExploreView: View { @State private var list: PagedListModel @State private var search: SearchViewModel init(client: GitbayClient) { _list = State(initialValue: PagedListModel( client: client, argv: ["explore"], emptyMessage: "This instance hosts no public repositories." )) _search = State(initialValue: SearchViewModel(client: client)) } /// One Text, not two: the HStack spaces its children, and a gap /// between the owner and the name would read as part of the path. private func path(of repo: PublicRepo) -> AttributedString { var owner = AttributedString(repo.owner + "/") owner.foregroundColor = .secondary var name = AttributedString(repo.name) name.font = .gbSans(.subheadline).weight(.medium) return owner + name } /// Same styling as `path(of:)`, but for a search result's already /// joined "owner/name" string. private func styledPath(_ path: String) -> AttributedString { guard let slash = path.firstIndex(of: "/") else { return AttributedString(path) } var owner = AttributedString(String(path[...slash])) owner.foregroundColor = .secondary var name = AttributedString(String(path[path.index(after: slash)...])) name.font = .gbSans(.subheadline).weight(.medium) return owner + name } var body: some View { List { if search.isSearching { Section { Picker("Kind", selection: Bindable(search).kind) { ForEach(SearchKind.allCases) { kind in Text(kind.label).tag(kind) } } .pickerStyle(.segmented) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) } ForEach(search.state.value ?? []) { result in searchRow(for: result) } } else { ForEach(list.state.value ?? []) { repo in NavigationLink(value: RepoRoute.repo(repo.path)) { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Text(path(of: repo)) if repo.isArchived { GBChip("archived", .secondary) } } .font(.gbSans(.subheadline)) .lineLimit(1) if let description = repo.description, !description.isEmpty { Text(description) .font(.gbSans(.caption)) .foregroundStyle(.secondary) .lineLimit(2) } if let topics = repo.topics, !topics.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(topics, id: \.self) { topic in GBChip(topic, .gbAccent) } } } } } .padding(.vertical, 2) } } PageFooter(list: list) } } .overlay { if search.isSearching { LoadStateOverlay(state: search.state) } else { LoadStateOverlay(state: list.state) } } .searchable(text: Bindable(search).query, prompt: "Search this instance") .navigationTitle("Explore") .task { if list.state.value == nil { await list.reload() } } .refreshable { await list.reload() } } @ViewBuilder private func searchRow(for result: SearchResult) -> some View { switch result.kind { case "repo": NavigationLink(value: RepoRoute.repo(result.repo)) { Text(styledPath(result.repo)) .font(.gbSans(.subheadline)) .lineLimit(1) .padding(.vertical, 2) } case "issue": if let number = result.number { NavigationLink(value: IssueRoute.issue(repo: result.repo, number: number)) { SearchEntryRow(result: result) } } else { SearchEntryRow(result: result) } case "mr": if let number = result.number { NavigationLink(value: MRRoute.mr(repo: result.repo, number: number)) { SearchEntryRow(result: result) } } else { SearchEntryRow(result: result) } default: SearchEntryRow(result: result) } } } /// An issue or merge request row from `search` — `#` for an issue, /// `!` for a merge request, this forge's convention for the two. private struct SearchEntryRow: View { let result: SearchResult var body: some View { VStack(alignment: .leading, spacing: 4) { Text(result.repo) .font(.gbSans(.caption)) .foregroundStyle(.secondary) .lineLimit(1) HStack(alignment: .firstTextBaseline, spacing: 6) { if let number = result.number { Text((result.kind == "issue" ? "#" : "!") + "\(number)") .font(.gbMono(.caption)) .foregroundStyle(.secondary) } Text(result.title) .font(.gbSans(.subheadline).weight(.medium)) .lineLimit(2) } HStack(spacing: 6) { if let state = result.state { GBChip(stateLabel(state), color(for: state)) } Spacer() if let updatedAt = result.updatedAt { Text(updatedAt, format: .relative(presentation: .named)) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } } } .padding(.vertical, 2) } private func color(for state: String) -> Color { switch state { case "open": .gbOK case "merged": .gbDone default: .gbBad } } }