@@ -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 .
207230actor 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}
0 commit comments