gitbay/Repos/CompareViewModel.swift

v1.0.6
gitbay-ios/gitbay/Repos/CompareViewModel.swift history · blame · raw

100 lines · 3573 bytes

  1import Foundation
  2import Observation
  3
  4/// `repo diff <owner/name> <base> <head>` — the same unified-diff shape as
  5/// `mr diff` and a commit patch, parsed the same way.
  6nonisolated struct RepoDiff: Decodable, Sendable, Hashable {
  7    let base: String
  8    let head: String
  9    let mergeBase: String
 10    let patch: String
 11    let truncated: Bool
 12
 13    enum CodingKeys: String, CodingKey {
 14        case base, head, patch, truncated
 15        case mergeBase = "merge_base"
 16    }
 17}
 18
 19/// Compares two refs in a repo. Both refs are positional and base comes
 20/// first — swapping them silently returns the opposite diff with no error.
 21@Observable
 22@MainActor
 23final class CompareViewModel {
 24
 25    private static let notComparedYet = "Pick two refs to compare."
 26
 27    var base = "" { didSet { resetResults() } }
 28    var head = "" { didSet { resetResults() } }
 29    private(set) var state: LoadState<UnifiedDiff> = .empty(notComparedYet)
 30    private(set) var truncated = false
 31    private(set) var mergeBase: String?
 32
 33    /// `repo refs`, for pickers. Exposed as an optional plus an error
 34    /// string rather than an empty array, so "failed to load" stays
 35    /// distinguishable from "this repository has no branches".
 36    private(set) var refs: RepoRefs?
 37    private(set) var refsError: String?
 38
 39    /// Bumped by `resetResults()` (so on every `base`/`head` change) and
 40    /// captured by `compare()` before it awaits. A response that arrives
 41    /// after the generation has moved on belongs to a ref pairing nobody
 42    /// is looking at any more, so it's discarded rather than written —
 43    /// otherwise a slow request for an old ref pair could land after a
 44    /// fast one for the current pair and overwrite it.
 45    private var generation = 0
 46
 47    private let client: GitbayClient
 48    let repoPath: String
 49
 50    init(client: GitbayClient, repoPath: String) {
 51        self.client = client
 52        self.repoPath = repoPath
 53    }
 54
 55    func loadRefs() async {
 56        refsError = nil
 57        refs = nil
 58        do {
 59            refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
 60        } catch let error as GitbayError {
 61            refsError = error.userFacingMessage
 62        } catch {
 63            refsError = GitbayError.transport(error).userFacingMessage
 64        }
 65    }
 66
 67    /// Clears whatever the previous compare left behind, so a re-compare
 68    /// (or a ref changed mid-flight) can never pair a stale merge base or
 69    /// truncation notice with the new state.
 70    private func resetResults() {
 71        generation += 1
 72        state = .empty(Self.notComparedYet)
 73        mergeBase = nil
 74        truncated = false
 75    }
 76
 77    func compare() async {
 78        let base = base.trimmingCharacters(in: .whitespaces)
 79        let head = head.trimmingCharacters(in: .whitespaces)
 80        guard !base.isEmpty, !head.isEmpty else { return }
 81
 82        let requestGeneration = generation
 83        state = .loading
 84        mergeBase = nil
 85        truncated = false
 86        do {
 87            let diff = try await client.read(["repo", "diff", repoPath, base, head], as: RepoDiff.self)
 88            guard requestGeneration == generation else { return }
 89            truncated = diff.truncated
 90            mergeBase = diff.mergeBase
 91            let parsed = UnifiedDiff.parse(diff.patch)
 92            state = parsed.files.isEmpty
 93                ? .empty("No differences between \(base) and \(head).")
 94                : .loaded(parsed)
 95        } catch {
 96            guard requestGeneration == generation else { return }
 97            state = .from(error)
 98        }
 99    }
100}