import SwiftUI struct SearchView: View { @Environment(\.dismiss) private var dismiss @AppStorage(RecentSearches.key) private var recent = "" @State private var query = "" @FocusState private var typing: Bool private var trimmed: String { query.trimmingCharacters(in: .whitespaces) } var body: some View { VStack(spacing: 0) { HStack(spacing: Space.s2_5) { Oct(name: "search", size: Icon.lg).foregroundStyle(.secondary) TextField("Search knotless", text: $query) .focused($typing) .textInputAutocapitalization(.never) .autocorrectionDisabled() .submitLabel(.search) if !query.isEmpty { Button { query = "" } label: { Oct(name: "x-circle-fill", size: Icon.lg).foregroundStyle(.secondary) } .buttonStyle(.plain) .accessibilityLabel("Clear") } Button { dismiss() } label: { Oct(name: "x", size: Icon.lg).foregroundStyle(.primary) } .buttonStyle(.plain) .accessibilityLabel("Close search") } .padding(.horizontal, Space.s4) .padding(.vertical, Space.s3) .background(Color.raised, in: Capsule()) .padding(.horizontal, Space.s3) .padding(.vertical, Space.s2) List { Group { if trimmed.isEmpty { recents } else { link(.searchCode(trimmed), "Code", "code") link(.searchRepositories(trimmed), "Repositories", "repo") link(.searchPeople(trimmed), "People", "person") NavigationLink(value: jump) { row("Jump to \"\(trimmed)\"", "arrow-right") } } } .listRowBackground(Color.page) } .listStyle(.plain) } .toolbar(.hidden, for: .navigationBar) .toolbar { ToolbarItem(placement: .keyboard) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Space.s2) { ForEach(["repo:", "file:", "lang:", "case:yes"], id: \.self) { filter in Button(filter) { add(filter) } .font(.subheadline.monospaced()) .foregroundStyle(.primary) .padding(.horizontal, Space.s2_5) } } } } } .onAppear { typing = true } } @ViewBuilder private var recents: some View { let searches = RecentSearches.decode(recent) if !searches.isEmpty { Section { ForEach(searches, id: \.self) { search in Button { query = search } label: { HStack { Text(search).foregroundStyle(.primary).lineLimit(1) Spacer() Oct(name: "arrow-up-left").foregroundStyle(.secondary) } } } } header: { HStack { Text("Recent searches").font(.title3.bold()).foregroundStyle(.primary) Spacer() Button("Clear") { recent = "" }.font(.subheadline) } .textCase(nil) } } } private func link(_ route: Route, _ what: String, _ icon: String) -> some View { NavigationLink(value: route) { row("\(what) with \"\(trimmed)\"", icon) } } private func row(_ title: String, _ icon: String) -> some View { HStack(spacing: Space.s3_5) { Oct(name: icon, size: Icon.xl).foregroundStyle(.secondary).frame(width: 26) Text(title).lineLimit(1) } .padding(.vertical, Space.s1_5) } /// `owner/name` is a repository; anything else is taken as an account. private var jump: Route { let parts = trimmed.split(separator: "/", maxSplits: 1).map(String.init) if parts.count == 2 { return .repo(RepoRef(did: parts[0], handle: parts[0], name: parts[1])) } return .profile(trimmed) } private func add(_ filter: String) { if !query.isEmpty, !query.hasSuffix(" ") { query += " " } query += filter } } /// Keeps what was searched, once its results are looked at. private struct RememberSearch: ViewModifier { @AppStorage(RecentSearches.key) private var recent = "" let query: String func body(content: Content) -> some View { content.onAppear { recent = RecentSearches.adding(query, to: recent) } } } struct CodeResultsView: View { let query: String var body: some View { PagedList(empty: "No code matches that.", fetch: { [query] cursor in let answer: CodeSearch = try await Client.shared.query("searchCode", ["q": query, "cursor": cursor]) return .init(items: answer.files ?? [], cursor: answer.cursor) }) { CodeHit(file: $0) } .modifier(RememberSearch(query: query)) .navigationTitle("Code") .navigationBarTitleDisplayMode(.inline) } } private struct CodeHit: View { let file: CodeSearch.File @State private var whole = false private var ref: RepoRef { file.repo.ref } private var shown: [CodeSearch.Chunk] { whole ? file.chunks ?? [] : Array((file.chunks ?? []).prefix(1)) } private var hidden: Int { (file.chunks ?? []).dropFirst().reduce(0) { $0 + ($1.ranges?.count ?? 0) } } var body: some View { VStack(alignment: .leading, spacing: Space.s2) { NavigationLink(value: Route.file(ref, ref: nil, path: file.path)) { VStack(alignment: .leading, spacing: Space.s1) { HStack(spacing: Space.s1_5) { Avatar(url: file.repo.owner?.avatar, name: ref.handle, did: ref.did, size: Size.Avatar.xsmall) Text(ref.handle).font(.subheadline.weight(.medium)).lineLimit(1) Text("/").foregroundStyle(.secondary) Text(ref.name).font(.subheadline.weight(.medium)).lineLimit(1) } Text(file.path).font(.subheadline).lineLimit(1).truncationMode(.head) if let language = file.language { Text(language).font(.caption).foregroundStyle(.secondary) } } } ForEach(shown, id: \.startLine) { ChunkLines(chunk: $0) } if !whole, hidden > 0 { Button(hidden == 1 ? "Show 1 more match" : "Show \(hidden) more matches") { whole = true } .font(.footnote) .buttonStyle(.borderless) } } .padding(.vertical, Space.s1_5) } } /// A run of lines from a search, its matches marked, scrolling sideways under /// numbers that stay put. private struct ChunkLines: View { let chunk: CodeSearch.Chunk var body: some View { let lines = chunk.content.components(separatedBy: "\n") let painted = chunk.marked(Highlight.paint(lines: lines, ranges: chunk.highlights), lines: lines) HStack(alignment: .top, spacing: Space.s2) { VStack(alignment: .trailing, spacing: Space.s0_5) { ForEach(lines.indices, id: \.self) { Text(String(chunk.startLine + $0)).foregroundStyle(.secondary) } } ScrollView(.horizontal, showsIndicators: false) { VStack(alignment: .leading, spacing: Space.s0_5) { ForEach(painted.indices, id: \.self) { Text(painted[$0]).lineLimit(1).fixedSize() } } } } .font(.caption.monospaced()) .padding(Space.s2) .background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.medium)) } } struct RepositoryResultsView: View { let query: String var body: some View { PagedList(empty: "No repositories match that.", fetch: { [query] cursor in let answer: RepoSearch = try await Client.shared.query("listRepos", ["q": query, "cursor": cursor]) return .init(items: answer.repos.map(\.repo), cursor: answer.cursor) }) { RepoHitRow(repo: $0) } .modifier(RememberSearch(query: query)) .navigationTitle("Repositories") .navigationBarTitleDisplayMode(.inline) } } struct RepoHitRow: View { let repo: Repo var body: some View { NavigationLink(value: Route.repo(repo.ref)) { VStack(alignment: .leading, spacing: Space.s1_5) { HStack(spacing: Space.s1_5) { Avatar(url: repo.owner.avatar, name: repo.owner.handle, did: repo.owner.did, size: Size.Avatar.xsmall) Text(repo.owner.handle).font(.subheadline).foregroundStyle(.secondary).lineLimit(1) } Text(repo.name).font(.headline).lineLimit(1) if let about = repo.description, !about.isEmpty { Text(about).lineLimit(4) } HStack(spacing: Space.s3) { if let stars = repo.starCount { HStack(spacing: Space.s1) { Oct(name: stars > 0 ? "star-fill" : "star") Text("\(stars)") } } if let language = repo.language { HStack(spacing: Space.s1_5) { Circle().fill(Color.hex(language.color) ?? .neutral).frame(width: 10, height: 10) Text(language.name) } } } .font(.subheadline) .foregroundStyle(.secondary) } .padding(.vertical, Space.s1) } } } struct PeopleResultsView: View { let query: String var body: some View { Loading(load: { [query] in let answer: OwnerSearch = try await Client.shared.query("searchOwners", ["q": query]) return answer.owners }) { owners in if owners.isEmpty { ContentUnavailableView("No one matches that", image: "oct-person") } else { List { ForEach(owners, id: \.owner.did) { found in NavigationLink(value: Route.profile(found.owner.handle)) { HStack(spacing: Space.s3) { Avatar(url: found.owner.avatar, name: found.owner.handle, did: found.owner.did, size: Size.Avatar.medium) VStack(alignment: .leading, spacing: Space.s0_5) { Text(found.owner.handle).lineLimit(1) Text(found.repoCount == 1 ? "1 repository" : "\(found.repoCount) repositories") .font(.footnote).foregroundStyle(.secondary) } } } }.listRowBackground(Color.page) } .listStyle(.plain) } } .modifier(RememberSearch(query: query)) .navigationTitle("People") .navigationBarTitleDisplayMode(.inline) } } extension CodeSearch.Chunk { /// `painted`, one for each of `lines`, with the matches marked. func marked(_ painted: [AttributedString], lines: [String]) -> [AttributedString] { var out = painted for match in ranges ?? [] { let at = match.line - startLine guard lines.indices.contains(at), out.indices.contains(at) else { continue } let text = lines[at].utf8 guard let from = text.index(text.startIndex, offsetBy: match.byteStart, limitedBy: text.endIndex), let to = text.index(text.startIndex, offsetBy: match.byteEnd, limitedBy: text.endIndex), let lower = AttributedString.Index(from, within: out[at]), let upper = AttributedString.Index(to, within: out[at]), lower < upper else { continue } out[at][lower..