Skip to content

Commit f676a04

Browse files
committed
perf(diff): commit-id-keyed LRU diff cache
Key the diff cache by the immutable commit id (falling back to rev) and fold ignoreWhitespace into the key, so amended/rebased changes and whitespace toggles never serve a stale diff. Bound DiffCache as a byte-budgeted LRU and drop the per-selection clear() so previously viewed changes stay warm for instant back-navigation. Adds DiffCacheTests.
1 parent 21d2427 commit f676a04

8 files changed

Lines changed: 180 additions & 24 deletions

File tree

shell/mac/Sources/JayJay/Detail/DetailHeader.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,11 @@ extension ChangeDetailView {
123123
loadTrackedGitLfsPaths()
124124
loadDiffStats()
125125
refreshReviewedPaths()
126-
diffStore.clear()
126+
// No clear(): content-addressed by commit id, so prior changes stay warm and never go stale.
127127
diffStore.preload(
128128
hunks: detail.diff,
129129
rev: detailRevision,
130+
commitId: detail.info.commitId,
130131
repo: repo,
131132
compareFromRev: compareFromId,
132133
ignoreWhitespace: appSettings.ignoreWhitespace

shell/mac/Sources/JayJay/Detail/DetailView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ struct ChangeDetailView: View {
288288
DiffSection(
289289
hunk: hunk,
290290
rev: detailRevision,
291+
commitId: detail.info.commitId,
291292
repo: repo,
292293
actions: actions,
293294
isWorkingCopy: detail.info.isWorkingCopy,

shell/mac/Sources/JayJay/Diff/DiffSection.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import SwiftUI
55
struct DiffSection: View, DiffGutterEditActions, DiffGutterReviewActions {
66
let hunk: DiffHunk
77
let rev: String?
8+
var commitId: String?
89
let repo: JayJayRepo?
910
let actions: (any ChangeActions & DAGActions)?
1011
let isWorkingCopy: Bool
@@ -279,11 +280,12 @@ struct DiffSection: View, DiffGutterEditActions, DiffGutterReviewActions {
279280
fileDiff = nil
280281

281282
if let cached = await diffStore.loadDiff(
282-
hunk: hunk, rev: rev, repo: repo,
283+
hunk: hunk, rev: rev, commitId: commitId, repo: repo,
283284
compareFromRev: compareFromRev,
284285
ignoreWhitespace: settings.ignoreWhitespace
285286
) {
286-
guard hunk.path == path else { return }
287+
// Bail if a newer .task superseded us so we don't overwrite fresh state with a stale diff.
288+
guard !Task.isCancelled, hunk.path == path else { return }
287289
fileDiff = cached.diff
288290
loadedOldContent = cached.oldContent
289291
loadedNewContent = cached.newContent

shell/mac/Sources/JayJay/Diff/DiffStore.swift

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,32 +14,32 @@ final class DiffStore {
1414
}
1515

1616
private let cache = DiffCache()
17-
18-
func get(rev: String?, path: String) async -> CachedDiff? {
19-
await cache.get(Self.key(rev: rev, path: path))
20-
}
21-
22-
func set(rev: String?, path: String, value: CachedDiff) async {
23-
await cache.set(Self.key(rev: rev, path: path), value: value)
24-
}
17+
@ObservationIgnored private var preloadTask: Task<Void, Never>?
2518

2619
func clear() {
2720
Task { await cache.clear() }
2821
}
2922

3023
/// Load a single file's diff. Returns cached if available, otherwise computes and caches.
24+
///
25+
/// `commitId` is the immutable content hash used as the cache identity. `rev`
26+
/// (the mutable selection revision) is what jj resolves to fetch content, but
27+
/// keying on it would serve stale diffs after an amend/rebase reuses the id.
3128
func loadDiff(
3229
hunk: DiffHunk,
3330
rev: String?,
31+
commitId: String? = nil,
3432
repo: JayJayRepo?,
3533
compareFromRev: String? = nil,
3634
ignoreWhitespace: Bool = false
3735
) async -> CachedDiff? {
3836
guard let repo else { return nil }
3937
guard !hunk.isSubmodulePlaceholder else { return nil }
4038

41-
let cacheRev = compareFromRev != nil ? "\(compareFromRev!)\(rev ?? "")" : rev
42-
let key = Self.key(rev: cacheRev, path: hunk.path)
39+
let key = Self.key(
40+
commitId: commitId, rev: rev, compareFromRev: compareFromRev,
41+
ignoreWhitespace: ignoreWhitespace, path: hunk.path
42+
)
4343

4444
if let cached = await cache.get(key) {
4545
return cached
@@ -81,28 +81,40 @@ final class DiffStore {
8181
func preload(
8282
hunks: [DiffHunk],
8383
rev: String?,
84+
commitId: String? = nil,
8485
repo: JayJayRepo?,
8586
compareFromRev: String? = nil,
8687
ignoreWhitespace: Bool = false
8788
) {
8889
guard let repo, let rev else { return }
89-
preloadHunks(hunks, rev: rev, repo: repo, compareFromRev: compareFromRev, ignoreWhitespace: ignoreWhitespace)
90+
// Cancel any in-flight preload so rapid commit navigation doesn't pile
91+
// up detached tasks all racing the FFI.
92+
preloadTask?.cancel()
93+
preloadTask = preloadHunks(
94+
hunks, rev: rev, commitId: commitId, repo: repo,
95+
compareFromRev: compareFromRev, ignoreWhitespace: ignoreWhitespace
96+
)
9097
}
9198

9299
// MARK: - Private
93100

101+
@discardableResult
94102
private func preloadHunks(
95103
_ hunks: [DiffHunk],
96104
rev: String,
105+
commitId: String?,
97106
repo: JayJayRepo,
98107
compareFromRev: String?,
99108
ignoreWhitespace: Bool
100-
) {
109+
) -> Task<Void, Never> {
101110
Task.detached(priority: .utility) { [cache] in
102111
for hunk in hunks {
112+
if Task.isCancelled { return }
103113
guard !hunk.isSubmodulePlaceholder else { continue }
104-
let cacheRev = compareFromRev != nil ? "\(compareFromRev!)\(rev)" : rev
105-
let key = DiffStore.key(rev: cacheRev, path: hunk.path)
114+
let key = DiffStore.key(
115+
commitId: commitId, rev: rev, compareFromRev: compareFromRev,
116+
ignoreWhitespace: ignoreWhitespace, path: hunk.path
117+
)
106118
if await cache.get(key) != nil { continue }
107119

108120
var old = hunk.oldContent ?? ""
@@ -198,24 +210,73 @@ final class DiffStore {
198210
return LoadedFileContent(oldContent: "", newContent: "", oldPreview: nil, newPreview: nil)
199211
}
200212

201-
nonisolated private static func key(rev: String?, path: String) -> String {
202-
"\(rev ?? "")|\(path)"
213+
/// Content-addressed cache key: immutable `commitId` (falling back to `rev`),
214+
/// the compare-from side, the whitespace mode, and the path. Whitespace is
215+
/// part of the key because it changes the computed diff for the same content.
216+
nonisolated static func key(
217+
commitId: String?,
218+
rev: String?,
219+
compareFromRev: String?,
220+
ignoreWhitespace: Bool,
221+
path: String
222+
) -> String {
223+
let base = (commitId?.isEmpty == false ? commitId : rev) ?? ""
224+
let identity = compareFromRev.map { "\($0)\(base)" } ?? base
225+
return "\(identity)|\(ignoreWhitespace ? "iw" : "")|\(path)"
203226
}
204227
}
205228

206-
/// Thread-safe diff cache actor.
229+
/// Thread-safe LRU diff cache bounded by the total bytes of cached file content.
207230
actor DiffCache {
208231
private var entries: [String: DiffStore.CachedDiff] = [:]
232+
private var order: [String] = [] // LRU recency; front = least recently used
233+
private var totalBytes = 0
234+
private let budgetBytes: Int
235+
236+
init(budgetBytes: Int = 64 * 1024 * 1024) {
237+
self.budgetBytes = budgetBytes
238+
}
209239

210240
func get(_ key: String) -> DiffStore.CachedDiff? {
211-
entries[key]
241+
guard let value = entries[key] else { return nil }
242+
touch(key)
243+
return value
212244
}
213245

214246
func set(_ key: String, value: DiffStore.CachedDiff) {
247+
if let existing = entries[key] {
248+
totalBytes -= bytes(existing)
249+
order.removeAll { $0 == key }
250+
}
215251
entries[key] = value
252+
order.append(key)
253+
totalBytes += bytes(value)
254+
evict()
216255
}
217256

218257
func clear() {
219258
entries.removeAll()
259+
order.removeAll()
260+
totalBytes = 0
261+
}
262+
263+
private func touch(_ key: String) {
264+
order.removeAll { $0 == key }
265+
order.append(key)
266+
}
267+
268+
/// Drop least-recently-used entries until under budget, always keeping the
269+
/// most recent one (so a single oversized file is still cached for its view).
270+
private func evict() {
271+
while totalBytes > budgetBytes, order.count > 1, let oldest = order.first {
272+
order.removeFirst()
273+
if let removed = entries.removeValue(forKey: oldest) {
274+
totalBytes -= bytes(removed)
275+
}
276+
}
277+
}
278+
279+
private func bytes(_ diff: DiffStore.CachedDiff) -> Int {
280+
diff.oldContent.utf8.count + diff.newContent.utf8.count
220281
}
221282
}

shell/mac/Sources/JayJay/DiffEdit/DiffEditFileSection.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import SwiftUI
55
struct DiffEditFileSection: View, DiffGutterSelectionActions {
66
let hunk: DiffHunk
77
let rev: String
8+
var commitId: String?
89
let repo: JayJayRepo?
910
let diffStore: DiffStore
1011
let selectedChangedLines: Set<Int>
@@ -129,7 +130,8 @@ struct DiffEditFileSection: View, DiffGutterSelectionActions {
129130

130131
// Reuse DiffStore for file content loading (cached if already loaded by DiffSection)
131132
let cached = await diffStore.loadDiff(
132-
hunk: hunk, rev: rev, repo: repo, ignoreWhitespace: settings.ignoreWhitespace
133+
hunk: hunk, rev: rev, commitId: commitId, repo: repo,
134+
ignoreWhitespace: settings.ignoreWhitespace
133135
)
134136
let old = cached?.oldContent
135137
let new = cached?.newContent

shell/mac/Sources/JayJay/DiffEdit/DiffEditView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ struct DiffEditView: View {
3232
DiffEditFileSection(
3333
hunk: hunk,
3434
rev: detailRevision,
35+
commitId: detail.info.commitId,
3536
repo: repo,
3637
diffStore: diffStore,
3738
selectedChangedLines: selectedChangedLinesByPath[hunk.path] ?? [],

shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Selection.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,19 @@ extension RepoViewModel {
226226
compareDisplay = display
227227
selectedChangeId = to
228228
load {
229-
try $0.interdiffSummary(fromRev: from, toRev: to)
230-
} onSuccess: { viewModel, detail in
229+
let detail = try $0.interdiffSummary(fromRev: from, toRev: to)
230+
// Resolve the compare source to its immutable commit id so the diff
231+
// cache key is content-addressed on both sides; otherwise amending a
232+
// mutable `from` (a change id) would keep serving a stale interdiff.
233+
let fromCommitId = (try? $0.log(revset: from))?.first?.commitId
234+
return (detail, fromCommitId)
235+
} onSuccess: { viewModel, result in
236+
let (detail, fromCommitId) = result
231237
viewModel.selectedChange = detail
232238
viewModel.selectedChangeId = detail.info.selectionRevision
239+
if let fromCommitId, !fromCommitId.isEmpty {
240+
viewModel.compareFromId = fromCommitId
241+
}
233242
} onFailure: { viewModel, error in
234243
viewModel.compareFromId = nil
235244
viewModel.compareToId = nil
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
@testable import JayJay
2+
import JayJayCore
3+
import XCTest
4+
5+
final class DiffCacheTests: XCTestCase {
6+
// MARK: - Content-addressed key
7+
8+
func testKeyUsesCommitIdOverRev() {
9+
// Same working-copy rev, different content hash (e.g. after an edit/amend)
10+
// must produce different keys so a stale diff is never served.
11+
let a = DiffStore.key(commitId: "c1", rev: "@", compareFromRev: nil, ignoreWhitespace: false, path: "f")
12+
let b = DiffStore.key(commitId: "c2", rev: "@", compareFromRev: nil, ignoreWhitespace: false, path: "f")
13+
XCTAssertNotEqual(a, b)
14+
}
15+
16+
func testKeyIgnoresRevWhenCommitIdPresent() {
17+
// A change keeps its content hash across selection-revision spellings.
18+
let a = DiffStore.key(commitId: "c1", rev: "abc", compareFromRev: nil, ignoreWhitespace: false, path: "f")
19+
let b = DiffStore.key(commitId: "c1", rev: "xyz", compareFromRev: nil, ignoreWhitespace: false, path: "f")
20+
XCTAssertEqual(a, b)
21+
}
22+
23+
func testKeyFallsBackToRevWhenCommitIdMissing() {
24+
let a = DiffStore.key(commitId: nil, rev: "r1", compareFromRev: nil, ignoreWhitespace: false, path: "f")
25+
let b = DiffStore.key(commitId: "", rev: "r1", compareFromRev: nil, ignoreWhitespace: false, path: "f")
26+
XCTAssertEqual(a, b, "empty commit id should fall back to rev")
27+
}
28+
29+
func testKeyDistinguishesWhitespaceMode() {
30+
let on = DiffStore.key(commitId: "c1", rev: nil, compareFromRev: nil, ignoreWhitespace: true, path: "f")
31+
let off = DiffStore.key(commitId: "c1", rev: nil, compareFromRev: nil, ignoreWhitespace: false, path: "f")
32+
XCTAssertNotEqual(on, off)
33+
}
34+
35+
func testKeyDistinguishesCompareSide() {
36+
let plain = DiffStore.key(commitId: "c1", rev: nil, compareFromRev: nil, ignoreWhitespace: false, path: "f")
37+
let compare = DiffStore.key(commitId: "c1", rev: nil, compareFromRev: "c0", ignoreWhitespace: false, path: "f")
38+
XCTAssertNotEqual(plain, compare)
39+
}
40+
41+
// MARK: - LRU eviction
42+
43+
func testEvictsLeastRecentlyUsedPastBudget() async {
44+
let cache = DiffCache(budgetBytes: 100)
45+
await cache.set("A", value: entry(bytes: 60))
46+
await cache.set("B", value: entry(bytes: 30)) // total 90, both fit
47+
48+
let a = await cache.get("A") // touch A → B is now least recently used
49+
XCTAssertNotNil(a)
50+
51+
await cache.set("C", value: entry(bytes: 30)) // total 120 > 100 → evict LRU (B)
52+
53+
let evicted = await cache.get("B")
54+
let keptA = await cache.get("A")
55+
let keptC = await cache.get("C")
56+
XCTAssertNil(evicted, "B was least recently used and should be evicted")
57+
XCTAssertNotNil(keptA)
58+
XCTAssertNotNil(keptC)
59+
}
60+
61+
func testKeepsSingleEntryLargerThanBudget() async {
62+
let cache = DiffCache(budgetBytes: 100)
63+
await cache.set("big", value: entry(bytes: 500))
64+
let stored = await cache.get("big")
65+
XCTAssertNotNil(stored, "the only entry is kept even when it exceeds the budget")
66+
}
67+
68+
// MARK: - Fixtures
69+
70+
private func entry(bytes: Int) -> DiffStore.CachedDiff {
71+
DiffStore.CachedDiff(
72+
diff: FileDiff(path: "f", language: "", lines: [], whitespaceOnlyHidden: false),
73+
oldContent: "",
74+
newContent: String(repeating: "x", count: bytes),
75+
oldPreview: nil,
76+
newPreview: nil
77+
)
78+
}
79+
}

0 commit comments

Comments
 (0)