gitbay/Discovery/SearchViewModel.swift

v1.11.0
gitbay-ios/gitbay/Discovery/SearchViewModel.swift history · blame · raw

114 lines · 3636 bytes

  1import Foundation
  2import Observation
  3
  4/// One row of `search` — a repository, an issue or a merge request.
  5/// Everything but `kind`, `repo` and `title` is omitempty on the wire: a
  6/// repository result carries none of it.
  7nonisolated struct SearchResult: Decodable, Sendable, Hashable, Identifiable {
  8    let kind: String
  9    let repo: String
 10    let number: Int64?
 11    let title: String
 12    let author: String?
 13    let state: String?
 14    let updatedAt: Date?
 15
 16    enum CodingKeys: String, CodingKey {
 17        case kind, repo, number, title, author, state
 18        case updatedAt = "updated_at"
 19    }
 20
 21    /// Unique across kinds: a repo row and an issue row in the same
 22    /// repository would otherwise share an identity.
 23    var id: String { "\(kind)/\(repo)/\(number.map(String.init) ?? "")" }
 24
 25    var isOpen: Bool { state == "open" }
 26}
 27
 28/// What `search` is narrowed to. `all` is the absence of `--kind`.
 29nonisolated enum SearchKind: String, CaseIterable, Identifiable, Sendable {
 30    case all, repo, issue, mr
 31
 32    var id: String { rawValue }
 33
 34    var label: String {
 35        switch self {
 36        case .all: "All"
 37        case .repo: "Repos"
 38        case .issue: "Issues"
 39        case .mr: "MRs"
 40        }
 41    }
 42
 43    var flag: [String] { self == .all ? [] : ["--kind", rawValue] }
 44}
 45
 46/// `search <query> [--kind ...]` across everything the caller can read.
 47/// Not paginated — the command takes no cursor.
 48@Observable
 49@MainActor
 50final class SearchViewModel {
 51
 52    var query = "" {
 53        didSet {
 54            guard query != oldValue else { return }
 55            scheduleSearch()
 56        }
 57    }
 58
 59    /// A picker, so it re-searches at once rather than debouncing.
 60    var kind: SearchKind = .all {
 61        didSet {
 62            guard kind != oldValue, isSearching else { return }
 63            searchTask?.cancel()
 64            state = .loading
 65            searchTask = Task { await run() }
 66        }
 67    }
 68
 69    private(set) var state: LoadState<[SearchResult]> = .loading
 70
 71    /// True once a non-blank query is present. The Explore listing shows
 72    /// through whenever this is false.
 73    var isSearching: Bool {
 74        !query.trimmingCharacters(in: .whitespaces).isEmpty
 75    }
 76
 77    private let client: GitbayClient
 78    private var searchTask: Task<Void, Never>?
 79
 80    init(client: GitbayClient) {
 81        self.client = client
 82    }
 83
 84    private func scheduleSearch() {
 85        searchTask?.cancel()
 86        guard isSearching else { return }
 87        // Keep prior results on screen while a refined search is in
 88        // flight, the way the repository list does.
 89        if state.value == nil { state = .loading }
 90        searchTask = Task { [weak self] in
 91            // Debounce a person typing, the way the repository list does.
 92            guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
 93            guard !Task.isCancelled else { return }
 94            await self?.run()
 95        }
 96    }
 97
 98    private func run() async {
 99        let text = query.trimmingCharacters(in: .whitespaces)
100        guard !text.isEmpty else { return }
101        do {
102            // Passed through unquoted: the server quotes FTS5 term by
103            // term, so an operator is a word to match, not a syntax error.
104            let results = try await client.readList(
105                ["search", text] + kind.flag, of: SearchResult.self
106            )
107            guard !Task.isCancelled else { return }
108            state = results.isEmpty ? .empty("Nothing matches “\(text)”.") : .loaded(results)
109        } catch {
110            guard !Task.isCancelled else { return }
111            state = .from(error)
112        }
113    }
114}