gitbay/Repos/CompareViewModel.swift
100 lines · 3573 bytes
5 symbols in this file
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}