Commit 2b5b408a5c

2b5b408a5cfbfe39c3d7d11436022dfba2c744a1

parent: c6150bdc21

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 22:17 UTC

Fix compare-refs loading overlay and stale-result review findings

The loading overlay covered the ref pickers on empty/failed states,
leaving no way to pick different refs or retry. Initialize state to
.empty instead of .loading, drop the hasCompared/isComparing view flags,
and render .empty/.failed inline as GBNotice so the overlay only covers
.loading.

Also reset state/mergeBase/truncated at the start of compare() and on
any base/head change, so a second compare or a ref change mid-flight
can't pair stale results with a new state. Clear refs alongside
refsError in loadRefs() for the same reason.

Add tests pinning the exit-3-is-empty-not-failed mapping, the reset
behavior across a failing re-compare, and the missing patch assertion
in decodesEveryField.

Layout: unified · split

gitbay/Repos/CompareViewModel.swift +17 −3
@@ -22,9 +22,11 @@ nonisolated struct RepoDiff: Decodable, Sendable, Hashable {
22@MainActor 22@MainActor
23final class CompareViewModel { 23final class CompareViewModel {
24 24
25 var base = "" 25 private static let notComparedYet = "Pick two refs to compare."
26 var head = "" 26
27 private(set) var state: LoadState<UnifiedDiff> = .loading 27 var base = "" { didSet { resetResults() } }
28 var head = "" { didSet { resetResults() } }
29 private(set) var state: LoadState<UnifiedDiff> = .empty(notComparedYet)
28 private(set) var truncated = false 30 private(set) var truncated = false
29 private(set) var mergeBase: String? 31 private(set) var mergeBase: String?
30 32
@@ -44,6 +46,7 @@ final class CompareViewModel {
44 46
45 func loadRefs() async { 47 func loadRefs() async {
46 refsError = nil 48 refsError = nil
49 refs = nil
47 do { 50 do {
48 refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self) 51 refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
49 } catch let error as GitbayError { 52 } catch let error as GitbayError {
@@ -53,12 +56,23 @@ final class CompareViewModel {
53 } 56 }
54 } 57 }
55 58
59 /// Clears whatever the previous compare left behind, so a re-compare
60 /// (or a ref changed mid-flight) can never pair a stale merge base or
61 /// truncation notice with the new state.
62 private func resetResults() {
63 state = .empty(Self.notComparedYet)
64 mergeBase = nil
65 truncated = false
66 }
67
56 func compare() async { 68 func compare() async {
57 let base = base.trimmingCharacters(in: .whitespaces) 69 let base = base.trimmingCharacters(in: .whitespaces)
58 let head = head.trimmingCharacters(in: .whitespaces) 70 let head = head.trimmingCharacters(in: .whitespaces)
59 guard !base.isEmpty, !head.isEmpty else { return } 71 guard !base.isEmpty, !head.isEmpty else { return }
60 72
61 state = .loading 73 state = .loading
74 mergeBase = nil
75 truncated = false
62 do { 76 do {
63 let diff = try await client.read(["repo", "diff", repoPath, base, head], as: RepoDiff.self) 77 let diff = try await client.read(["repo", "diff", repoPath, base, head], as: RepoDiff.self)
64 truncated = diff.truncated 78 truncated = diff.truncated
gitbay/Views/Repos/CompareView.swift +19 −10
@@ -6,12 +6,6 @@ import SwiftUI
6struct CompareView: View { 6struct CompareView: View {
7 7
8 @State private var model: CompareViewModel 8 @State private var model: CompareViewModel
9 /// `model.state` defaults to `.loading` before any comparison is
10 /// requested, since `compare()` is manually triggered rather than
11 /// run from `.task`. This tracks whether that default is a real
12 /// request in flight, so the overlay doesn't spin before the user
13 /// has picked anything.
14 @State private var hasCompared = false
15 9
16 init(client: GitbayClient, repo: String, defaultBranch: String? = nil) { 10 init(client: GitbayClient, repo: String, defaultBranch: String? = nil) {
17 let model = CompareViewModel(client: client, repoPath: repo) 11 let model = CompareViewModel(client: client, repoPath: repo)
@@ -22,8 +16,8 @@ struct CompareView: View {
22 } 16 }
23 17
24 private var isComparing: Bool { 18 private var isComparing: Bool {
25 guard hasCompared, case .loading = model.state else { return false } 19 if case .loading = model.state { return true }
26 return true 20 return false
27 } 21 }
28 22
29 var body: some View { 23 var body: some View {
@@ -32,7 +26,6 @@ struct CompareView: View {
32 26
33 Section { 27 Section {
34 Button("Compare") { 28 Button("Compare") {
35 hasCompared = true
36 Task { await model.compare() } 29 Task { await model.compare() }
37 } 30 }
38 .disabled(isComparing 31 .disabled(isComparing
@@ -59,6 +52,22 @@ struct CompareView: View {
59 } 52 }
60 } 53 }
61 54
55 // `.loading` is shown by the overlay below; `.empty` and
56 // `.failed` render inline so the pickers above stay reachable
57 // instead of being covered by a frame-filling overlay.
58 switch model.state {
59 case .empty(let message):
60 Section {
61 GBNotice(message, .gbWarn)
62 }
63 case .failed(let message):
64 Section {
65 GBNotice(message, .gbWarn)
66 }
67 case .loading, .loaded:
68 EmptyView()
69 }
70
62 if let diff = model.state.value, !diff.files.isEmpty { 71 if let diff = model.state.value, !diff.files.isEmpty {
63 ForEach(diff.files) { file in 72 ForEach(diff.files) { file in
64 DiffFileSection(file: file) 73 DiffFileSection(file: file)
@@ -66,7 +75,7 @@ struct CompareView: View {
66 } 75 }
67 } 76 }
68 .overlay { 77 .overlay {
69 if hasCompared { 78 if isComparing {
70 LoadStateOverlay(state: model.state) 79 LoadStateOverlay(state: model.state)
71 } 80 }
72 } 81 }
gitbayTests/CompareTests.swift +46
@@ -34,6 +34,7 @@ struct RepoDiffDecodingTests {
34 #expect(diff.base == "aaa111") 34 #expect(diff.base == "aaa111")
35 #expect(diff.head == "bbb222") 35 #expect(diff.head == "bbb222")
36 #expect(diff.mergeBase == "ccc333") 36 #expect(diff.mergeBase == "ccc333")
37 #expect(diff.patch == "x")
37 #expect(diff.truncated) 38 #expect(diff.truncated)
38 } 39 }
39} 40}
@@ -154,4 +155,49 @@ struct CompareViewModelTests {
154 return 155 return
155 } 156 }
156 } 157 }
158
159 /// The realistic failure from `repo diff` on an unresolvable ref is
160 /// exit 3, which `GitbayError.isEmptyState` maps to `.empty`, not
161 /// `.failed` — pinning that mapping rather than assuming it.
162 @Test func anUnresolvableRefIsEmptyNotFailed() async throws {
163 let (client, stub) = try makeClient()
164 stub.enqueue(.init(status: 200, json: """
165 {"protocol_version":1,"error":"unknown revision","exit_code":3}
166 """))
167 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
168 model.base = "main"
169 model.head = "nope"
170 await model.compare()
171
172 guard case .empty = model.state else {
173 Testing.Issue.record("expected empty, got \(model.state)")
174 return
175 }
176 }
177
178 /// A second compare that fails must not show the first compare's
179 /// merge base or truncation notice beside the failed state.
180 @Test func aFailedRecompareDropsTheStaleResult() async throws {
181 let (client, stub) = try makeClient()
182 stub.enqueue(.init(status: 200, json: patchJSON))
183 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
184 model.base = "main"
185 model.head = "feature"
186 await model.compare()
187
188 #expect(model.mergeBase == "ccc333")
189 #expect(model.state.value != nil)
190
191 stub.enqueue(.init(status: 200, json: """
192 {"protocol_version":1,"error":"unknown revision","exit_code":1}
193 """))
194 await model.compare()
195
196 guard case .failed = model.state else {
197 Testing.Issue.record("expected failed, got \(model.state)")
198 return
199 }
200 #expect(model.mergeBase == nil)
201 #expect(model.truncated == false)
202 }
157} 203}