gitbay/Discovery/SearchViewModel.swift
114 lines · 3636 bytes
4 symbols in this file
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}