fix(ui): prevent memory leak in diff view with many modified files

- Add LRU eviction to diffCache (max 30 entries / 20MB)
- Single-file mode: render only selected diff, not all hidden ones
- Stacked mode: collapse files beyond first 10 by default
- Limit DiffWorkerProvider warmup to 10 files
- Reduce worker pool size (4 to 2) and AST cache (200 to 50)
This commit is contained in:
Bohdan Triapitsyn
2026-01-28 12:29:01 +02:00
parent 8c6eb3c44d
commit 91e8b9e1f5
3 changed files with 82 additions and 34 deletions
+49 -2
View File
@@ -15,6 +15,10 @@ const DIFF_PREFETCH_MAX_FILES = 25;
const DIFF_PREFETCH_CONCURRENCY = 4;
const DIFF_PREFETCH_TIMEOUT_MS = 15000;
// Diff cache limits to prevent memory bloat with many modified files
const DIFF_CACHE_MAX_ENTRIES = 30;
const DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
interface DirectoryGitState {
isGitRepo: boolean | null;
status: GitStatus | null;
@@ -92,6 +96,45 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({
logMaxCount: 25,
});
// LRU eviction helper for diff cache
const evictDiffCacheIfNeeded = (
diffCache: Map<string, { original: string; modified: string; fetchedAt: number }>,
maxEntries: number = DIFF_CACHE_MAX_ENTRIES,
maxTotalSize: number = DIFF_CACHE_MAX_TOTAL_SIZE_BYTES
): Map<string, { original: string; modified: string; fetchedAt: number }> => {
// Calculate total size
let totalSize = 0;
for (const entry of diffCache.values()) {
totalSize += (entry.original?.length ?? 0) + (entry.modified?.length ?? 0);
}
// If within limits, return as-is
if (diffCache.size <= maxEntries && totalSize <= maxTotalSize) {
return diffCache;
}
// Sort entries by fetchedAt (oldest first) for LRU eviction
const entries = Array.from(diffCache.entries())
.sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
const newCache = new Map<string, { original: string; modified: string; fetchedAt: number }>();
let newTotalSize = 0;
// Keep entries from newest to oldest until limits are reached
for (let i = entries.length - 1; i >= 0; i--) {
const [path, entry] = entries[i];
const entrySize = (entry.original?.length ?? 0) + (entry.modified?.length ?? 0);
if (newCache.size >= maxEntries) break;
if (newTotalSize + entrySize > maxTotalSize && newCache.size > 0) continue;
newCache.set(path, entry);
newTotalSize += entrySize;
}
return newCache;
};
const haveDiffStatsChanged = (
previous?: GitStatus['diffStats'],
next?: GitStatus['diffStats']
@@ -410,7 +453,9 @@ export const useGitStore = create<GitStore>()(
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
const newDiffCache = new Map(dirState.diffCache);
newDiffCache.set(filePath, { ...diff, fetchedAt: Date.now() });
newDirectories.set(directory, { ...dirState, diffCache: newDiffCache });
// Apply LRU eviction to prevent memory bloat
const evictedCache = evictDiffCacheIfNeeded(newDiffCache);
newDirectories.set(directory, { ...dirState, diffCache: evictedCache });
set({ directories: newDirectories });
},
@@ -486,7 +531,9 @@ export const useGitStore = create<GitStore>()(
});
});
newDirectories.set(directory, { ...currentDirState, diffCache: newDiffCache });
// Apply LRU eviction to prevent memory bloat
const evictedCache = evictDiffCacheIfNeeded(newDiffCache);
newDirectories.set(directory, { ...currentDirState, diffCache: evictedCache });
set({ directories: newDirectories });
},