import Foundation import Observation /// `repo diff ` — the same unified-diff shape as /// `mr diff` and a commit patch, parsed the same way. nonisolated struct RepoDiff: Decodable, Sendable, Hashable { let base: String let head: String let mergeBase: String let patch: String let truncated: Bool enum CodingKeys: String, CodingKey { case base, head, patch, truncated case mergeBase = "merge_base" } } /// Compares two refs in a repo. Both refs are positional and base comes /// first — swapping them silently returns the opposite diff with no error. @Observable @MainActor final class CompareViewModel { private static let notComparedYet = "Pick two refs to compare." var base = "" { didSet { resetResults() } } var head = "" { didSet { resetResults() } } private(set) var state: LoadState = .empty(notComparedYet) private(set) var truncated = false private(set) var mergeBase: String? /// `repo refs`, for pickers. Exposed as an optional plus an error /// string rather than an empty array, so "failed to load" stays /// distinguishable from "this repository has no branches". private(set) var refs: RepoRefs? private(set) var refsError: String? /// Bumped by `resetResults()` (so on every `base`/`head` change) and /// captured by `compare()` before it awaits. A response that arrives /// after the generation has moved on belongs to a ref pairing nobody /// is looking at any more, so it's discarded rather than written — /// otherwise a slow request for an old ref pair could land after a /// fast one for the current pair and overwrite it. private var generation = 0 private let client: GitbayClient let repoPath: String init(client: GitbayClient, repoPath: String) { self.client = client self.repoPath = repoPath } func loadRefs() async { refsError = nil refs = nil do { refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self) } catch let error as GitbayError { refsError = error.userFacingMessage } catch { refsError = GitbayError.transport(error).userFacingMessage } } /// Clears whatever the previous compare left behind, so a re-compare /// (or a ref changed mid-flight) can never pair a stale merge base or /// truncation notice with the new state. private func resetResults() { generation += 1 state = .empty(Self.notComparedYet) mergeBase = nil truncated = false } func compare() async { let base = base.trimmingCharacters(in: .whitespaces) let head = head.trimmingCharacters(in: .whitespaces) guard !base.isEmpty, !head.isEmpty else { return } let requestGeneration = generation state = .loading mergeBase = nil truncated = false do { let diff = try await client.read(["repo", "diff", repoPath, base, head], as: RepoDiff.self) guard requestGeneration == generation else { return } truncated = diff.truncated mergeBase = diff.mergeBase let parsed = UnifiedDiff.parse(diff.patch) state = parsed.files.isEmpty ? .empty("No differences between \(base) and \(head).") : .loaded(parsed) } catch { guard requestGeneration == generation else { return } state = .from(error) } } }