import Foundation import Observation /// One row of `search` — a repository, an issue or a merge request. /// Everything but `kind`, `repo` and `title` is omitempty on the wire: a /// repository result carries none of it. nonisolated struct SearchResult: Decodable, Sendable, Hashable, Identifiable { let kind: String let repo: String let number: Int64? let title: String let author: String? let state: String? let updatedAt: Date? enum CodingKeys: String, CodingKey { case kind, repo, number, title, author, state case updatedAt = "updated_at" } /// Unique across kinds: a repo row and an issue row in the same /// repository would otherwise share an identity. var id: String { "\(kind)/\(repo)/\(number.map(String.init) ?? "")" } var isOpen: Bool { state == "open" } } /// What `search` is narrowed to. `all` is the absence of `--kind`. nonisolated enum SearchKind: String, CaseIterable, Identifiable, Sendable { case all, repo, issue, mr var id: String { rawValue } var label: String { switch self { case .all: "All" case .repo: "Repos" case .issue: "Issues" case .mr: "MRs" } } var flag: [String] { self == .all ? [] : ["--kind", rawValue] } } /// `search [--kind ...]` across everything the caller can read. /// Not paginated — the command takes no cursor. @Observable @MainActor final class SearchViewModel { var query = "" { didSet { guard query != oldValue else { return } scheduleSearch() } } /// A picker, so it re-searches at once rather than debouncing. var kind: SearchKind = .all { didSet { guard kind != oldValue, isSearching else { return } searchTask?.cancel() state = .loading searchTask = Task { await run() } } } private(set) var state: LoadState<[SearchResult]> = .loading /// True once a non-blank query is present. The Explore listing shows /// through whenever this is false. var isSearching: Bool { !query.trimmingCharacters(in: .whitespaces).isEmpty } private let client: GitbayClient private var searchTask: Task? init(client: GitbayClient) { self.client = client } private func scheduleSearch() { searchTask?.cancel() guard isSearching else { return } // Keep prior results on screen while a refined search is in // flight, the way the repository list does. if state.value == nil { state = .loading } searchTask = Task { [weak self] in // Debounce a person typing, the way the repository list does. guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return } guard !Task.isCancelled else { return } await self?.run() } } private func run() async { let text = query.trimmingCharacters(in: .whitespaces) guard !text.isEmpty else { return } do { // Passed through unquoted: the server quotes FTS5 term by // term, so an operator is a word to match, not a syntax error. let results = try await client.readList( ["search", text] + kind.flag, of: SearchResult.self ) guard !Task.isCancelled else { return } state = results.isEmpty ? .empty("Nothing matches “\(text)”.") : .loaded(results) } catch { guard !Task.isCancelled else { return } state = .from(error) } } }