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 {
2222@MainActor
2323final class CompareViewModel {
2424
25 var base = ""
26 var head = ""
27 private(set) var state: LoadState<UnifiedDiff> = .loading
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)
2830 private(set) var truncated = false
2931 private(set) var mergeBase: String?
3032
@@ -44,6 +46,7 @@ final class CompareViewModel {
4446
4547 func loadRefs() async {
4648 refsError = nil
49 refs = nil
4750 do {
4851 refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
4952 } catch let error as GitbayError {
@@ -53,12 +56,23 @@ final class CompareViewModel {
5356 }
5457 }
5558
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
5668 func compare() async {
5769 let base = base.trimmingCharacters(in: .whitespaces)
5870 let head = head.trimmingCharacters(in: .whitespaces)
5971 guard !base.isEmpty, !head.isEmpty else { return }
6072
6173 state = .loading
74 mergeBase = nil
75 truncated = false
6276 do {
6377 let diff = try await client.read(["repo", "diff", repoPath, base, head], as: RepoDiff.self)
6478 truncated = diff.truncated
gitbay/Views/Repos/CompareView.swift +19 −10
@@ -6,12 +6,6 @@ import SwiftUI
66struct CompareView: View {
77
88 @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
159
1610 init(client: GitbayClient, repo: String, defaultBranch: String? = nil) {
1711 let model = CompareViewModel(client: client, repoPath: repo)
@@ -22,8 +16,8 @@ struct CompareView: View {
2216 }
2317
2418 private var isComparing: Bool {
25 guard hasCompared, case .loading = model.state else { return false }
26 return true
19 if case .loading = model.state { return true }
20 return false
2721 }
2822
2923 var body: some View {
@@ -32,7 +26,6 @@ struct CompareView: View {
3226
3327 Section {
3428 Button("Compare") {
35 hasCompared = true
3629 Task { await model.compare() }
3730 }
3831 .disabled(isComparing
@@ -59,6 +52,22 @@ struct CompareView: View {
5952 }
6053 }
6154
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
6271 if let diff = model.state.value, !diff.files.isEmpty {
6372 ForEach(diff.files) { file in
6473 DiffFileSection(file: file)
@@ -66,7 +75,7 @@ struct CompareView: View {
6675 }
6776 }
6877 .overlay {
69 if hasCompared {
78 if isComparing {
7079 LoadStateOverlay(state: model.state)
7180 }
7281 }
gitbayTests/CompareTests.swift +46
@@ -34,6 +34,7 @@ struct RepoDiffDecodingTests {
3434 #expect(diff.base == "aaa111")
3535 #expect(diff.head == "bbb222")
3636 #expect(diff.mergeBase == "ccc333")
37 #expect(diff.patch == "x")
3738 #expect(diff.truncated)
3839 }
3940}
@@ -154,4 +155,49 @@ struct CompareViewModelTests {
154155 return
155156 }
156157 }
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 }
157203}