import Foundation import Observation /// `repo grep ` — server-side git grep, literal and /// case-insensitive. @Observable @MainActor final class GrepViewModel { nonisolated struct Match: Decodable, Sendable, Hashable, Identifiable { let path: String let line: Int let text: String var id: String { "\(path):\(line)" } } /// nil until the first search; distinct from an empty result. private(set) var state: LoadState<[Match]>? private(set) var lastQuery = "" private let client: GitbayClient let repoPath: String init(client: GitbayClient, repoPath: String) { self.client = client self.repoPath = repoPath } /// Matches grouped by file, in server order. var byFile: [(file: String, matches: [Match])] { guard let matches = state?.value else { return [] } var order: [String] = [] var groups: [String: [Match]] = [:] for match in matches { if groups[match.path] == nil { order.append(match.path) } groups[match.path, default: []].append(match) } return order.map { ($0, groups[$0]!) } } func search(_ query: String) async { let trimmed = query.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } lastQuery = trimmed state = .loading do { let matches = try await client.readList( ["repo", "grep", repoPath, trimmed], of: Match.self ) state = matches.isEmpty ? .empty("No matches for \"\(trimmed)\".") : .loaded(matches) } catch { state = .from(error) } } }