gitbay/Discovery/GrepViewModel.swift
57 lines · 1738 bytes
1import Foundation
2import Observation
3
4/// `repo grep <owner/name> <query>` — server-side git grep, literal and
5/// case-insensitive.
6@Observable
7@MainActor
8final class GrepViewModel {
9
10 nonisolated struct Match: Decodable, Sendable, Hashable, Identifiable {
11 let path: String
12 let line: Int
13 let text: String
14 var id: String { "\(path):\(line)" }
15 }
16
17 /// nil until the first search; distinct from an empty result.
18 private(set) var state: LoadState<[Match]>?
19 private(set) var lastQuery = ""
20
21 private let client: GitbayClient
22 let repoPath: String
23
24 init(client: GitbayClient, repoPath: String) {
25 self.client = client
26 self.repoPath = repoPath
27 }
28
29 /// Matches grouped by file, in server order.
30 var byFile: [(file: String, matches: [Match])] {
31 guard let matches = state?.value else { return [] }
32 var order: [String] = []
33 var groups: [String: [Match]] = [:]
34 for match in matches {
35 if groups[match.path] == nil { order.append(match.path) }
36 groups[match.path, default: []].append(match)
37 }
38 return order.map { ($0, groups[$0]!) }
39 }
40
41 func search(_ query: String) async {
42 let trimmed = query.trimmingCharacters(in: .whitespaces)
43 guard !trimmed.isEmpty else { return }
44 lastQuery = trimmed
45 state = .loading
46 do {
47 let matches = try await client.readList(
48 ["repo", "grep", repoPath, trimmed], of: Match.self
49 )
50 state = matches.isEmpty
51 ? .empty("No matches for \"\(trimmed)\".")
52 : .loaded(matches)
53 } catch {
54 state = .from(error)
55 }
56 }
57}