johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
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<CodeSearch.File, CodeHit>(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<Repo, RepoHitRow>(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..<upper].backgroundColor = .yellow.opacity(0.35)
}
return out
}
}