Skip to content

Commit fa962a6

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 fa962a6

7 files changed

Lines changed: 163 additions & 20 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,12 @@ extension ChangeDetailView {
123123
loadTrackedGitLfsPaths()
124124
loadDiffStats()
125125
refreshReviewedPaths()
126-
diffStore.clear()
126+
// No clear(): the cache is content-addressed by commit id, so prior
127+
// changes stay warm for instant back-navigation and never go stale.
127128
diffStore.preload(
128129
hunks: detail.diff,
129130
rev: detailRevision,
131+
commitId: detail.info.commitId,
130132
repo: repo,
131133
compareFromRev: compareFromId,
132134
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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import SwiftUI
55
struct DiffSection: View, DiffGutterEditActions, DiffGutterReviewActions {
66
let hunk: DiffHunk
77
let rev: String?
8+
/// Immutable content hash used as the diff cache identity (falls back to `rev`).
9+
var commitId: String?
810
let repo: JayJayRepo?
911
let actions: (any ChangeActions & DAGActions)?
1012
let isWorkingCopy: Bool
@@ -279,7 +281,7 @@ struct DiffSection: View, DiffGutterEditActions, DiffGutterReviewActions {
279281
fileDiff = nil
280282

281283
if let cached = await diffStore.loadDiff(
282-
hunk: hunk, rev: rev, repo: repo,
284+
hunk: hunk, rev: rev, commitId: commitId, repo: repo,
283285
compareFromRev: compareFromRev,
284286
ignoreWhitespace: settings.ignoreWhitespace
285287
) {

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

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,31 +15,30 @@ final class DiffStore {
1515

1616
private let cache = DiffCache()
1717

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-
}
25-
2618
func clear() {
2719
Task { await cache.clear() }
2820
}
2921

3022
/// Load a single file's diff. Returns cached if available, otherwise computes and caches.
23+
///
24+
/// `commitId` is the immutable content hash used as the cache identity. `rev`
25+
/// (the mutable selection revision) is what jj resolves to fetch content, but
26+
/// keying on it would serve stale diffs after an amend/rebase reuses the id.
3127
func loadDiff(
3228
hunk: DiffHunk,
3329
rev: String?,
30+
commitId: String? = nil,
3431
repo: JayJayRepo?,
3532
compareFromRev: String? = nil,
3633
ignoreWhitespace: Bool = false
3734
) async -> CachedDiff? {
3835
guard let repo else { return nil }
3936
guard !hunk.isSubmodulePlaceholder else { return nil }
4037

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

4443
if let cached = await cache.get(key) {
4544
return cached
@@ -81,28 +80,35 @@ final class DiffStore {
8180
func preload(
8281
hunks: [DiffHunk],
8382
rev: String?,
83+
commitId: String? = nil,
8484
repo: JayJayRepo?,
8585
compareFromRev: String? = nil,
8686
ignoreWhitespace: Bool = false
8787
) {
8888
guard let repo, let rev else { return }
89-
preloadHunks(hunks, rev: rev, repo: repo, compareFromRev: compareFromRev, ignoreWhitespace: ignoreWhitespace)
89+
preloadHunks(
90+
hunks, rev: rev, commitId: commitId, repo: repo,
91+
compareFromRev: compareFromRev, ignoreWhitespace: ignoreWhitespace
92+
)
9093
}
9194

9295
// MARK: - Private
9396

9497
private func preloadHunks(
9598
_ hunks: [DiffHunk],
9699
rev: String,
100+
commitId: String?,
97101
repo: JayJayRepo,
98102
compareFromRev: String?,
99103
ignoreWhitespace: Bool
100104
) {
101105
Task.detached(priority: .utility) { [cache] in
102106
for hunk in hunks {
103107
guard !hunk.isSubmodulePlaceholder else { continue }
104-
let cacheRev = compareFromRev != nil ? "\(compareFromRev!)\(rev)" : rev
105-
let key = DiffStore.key(rev: cacheRev, path: hunk.path)
108+
let key = DiffStore.key(
109+
commitId: commitId, rev: rev, compareFromRev: compareFromRev,
110+
ignoreWhitespace: ignoreWhitespace, path: hunk.path
111+
)
106112
if await cache.get(key) != nil { continue }
107113

108114
var old = hunk.oldContent ?? ""
@@ -198,24 +204,73 @@ final class DiffStore {
198204
return LoadedFileContent(oldContent: "", newContent: "", oldPreview: nil, newPreview: nil)
199205
}
200206

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

206-
/// Thread-safe diff cache actor.
223+
/// Thread-safe LRU diff cache bounded by the total bytes of cached file content.
207224
actor DiffCache {
208225
private var entries: [String: DiffStore.CachedDiff] = [:]
226+
private var order: [String] = [] // LRU recency; front = least recently used
227+
private var totalBytes = 0
228+
private let budgetBytes: Int
229+
230+
init(budgetBytes: Int = 64 * 1024 * 1024) {
231+
self.budgetBytes = budgetBytes
232+
}
209233

210234
func get(_ key: String) -> DiffStore.CachedDiff? {
211-
entries[key]
235+
guard let value = entries[key] else { return nil }
236+
touch(key)
237+
return value
212238
}
213239

214240
func set(_ key: String, value: DiffStore.CachedDiff) {
241+
if let existing = entries[key] {
242+
totalBytes -= bytes(existing)
243+
order.removeAll { $0 == key }
244+
}
215245
entries[key] = value
246+
order.append(key)
247+
totalBytes += bytes(value)
248+
evict()
216249
}
217250

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

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import SwiftUI
55
struct DiffEditFileSection: View, DiffGutterSelectionActions {
66
let hunk: DiffHunk
77
let rev: String
8+
/// Immutable content hash; shares the cache identity with DiffSection.
9+
var commitId: String?
810
let repo: JayJayRepo?
911
let diffStore: DiffStore
1012
let selectedChangedLines: Set<Int>
@@ -129,7 +131,8 @@ struct DiffEditFileSection: View, DiffGutterSelectionActions {
129131

130132
// Reuse DiffStore for file content loading (cached if already loaded by DiffSection)
131133
let cached = await diffStore.loadDiff(
132-
hunk: hunk, rev: rev, repo: repo, ignoreWhitespace: settings.ignoreWhitespace
134+
hunk: hunk, rev: rev, commitId: commitId, repo: repo,
135+
ignoreWhitespace: settings.ignoreWhitespace
133136
)
134137
let old = cached?.oldContent
135138
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] ?? [],
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)