~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
296 lines · 12.3 KB Raw History
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
        }
}