feat: optimize diff loading performance with background pre-fetching

This commit is contained in:
Bohdan Triapitsyn
2025-12-17 16:16:28 +02:00
parent 34353e1970
commit 60dea43cc3
6 changed files with 346 additions and 146 deletions
+2
View File
@@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file.
- Added image preview support in Diff tab (shows original/modified images instead of base64 code)
- Improved diff view visuals and alligned style among different widgets
- Optimized git polling and background diff+syntax pre-warm for instant Diff tab open
- Optomized reloading unaffected diffs
## [1.2.2] - 2025-12-17
+56 -137
View File
@@ -235,6 +235,45 @@ const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
);
});
interface DiffViewerEntryProps {
directory: string;
filePath: string;
isVisible: boolean;
renderSideBySide: boolean;
wrapLines: boolean;
}
const DiffViewerEntry = React.memo<DiffViewerEntryProps>(({
directory,
filePath,
isVisible,
renderSideBySide,
wrapLines,
}) => {
const cachedDiff = useGitStore(
React.useCallback((state) => {
return state.directories.get(directory)?.diffCache.get(filePath) ?? null;
}, [directory, filePath])
);
const diffData = React.useMemo(() => {
if (!cachedDiff) return null;
return { original: cachedDiff.original, modified: cachedDiff.modified };
}, [cachedDiff?.original, cachedDiff?.modified]);
if (!diffData) return null;
return (
<SingleDiffViewer
filePath={filePath}
diff={diffData}
isVisible={isVisible}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
/>
);
});
const useEffectiveDirectory = () => {
const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore();
const { currentDirectory: fallbackDirectory } = useDirectoryStore();
@@ -254,11 +293,9 @@ export const DiffView: React.FC = () => {
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
const status = useGitStatus(effectiveDirectory ?? null);
const isLoadingStatus = useGitStore((state) => state.isLoadingStatus);
const { setActiveDirectory, fetchStatus, setDiff } = useGitStore();
const { setActiveDirectory, fetchStatus } = useGitStore();
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
const [allDiffs, setAllDiffs] = React.useState<Map<string, DiffData>>(new Map());
const [loadingFiles, setLoadingFiles] = React.useState<Set<string>>(new Set());
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
@@ -267,10 +304,6 @@ export const DiffView: React.FC = () => {
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
const diffWrapLines = useUIStore((state) => state.diffWrapLines);
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
const lastStatusChange = useGitStore(React.useCallback((state) => {
if (!effectiveDirectory) return 0;
return state.directories.get(effectiveDirectory)?.lastStatusChange ?? 0;
}, [effectiveDirectory]));
const changedFiles: FileEntry[] = React.useMemo(() => {
if (!status?.files) return [];
@@ -352,142 +385,30 @@ export const DiffView: React.FC = () => {
}
}, [changedFiles, selectedFile]);
// PRE-FETCH ALL DIFFS when changedFiles changes
React.useEffect(() => {
if (!effectiveDirectory || changedFiles.length === 0) return;
const fetchAllDiffs = async () => {
const filesToFetch = changedFiles.filter((file) => !allDiffs.has(file.path));
if (filesToFetch.length === 0) return;
// Mark all as loading
setLoadingFiles((prev) => {
const next = new Set(prev);
filesToFetch.forEach((f) => next.add(f.path));
return next;
});
// Fetch all in parallel
const results = await Promise.allSettled(
filesToFetch.map(async (file) => {
const dirState = useGitStore.getState().directories.get(effectiveDirectory);
const cached = dirState?.diffCache.get(file.path);
if (cached && cached.fetchedAt >= (dirState?.lastStatusChange || 0)) {
return { path: file.path, diff: { original: cached.original, modified: cached.modified } };
}
const response = await git.getGitFileDiff(effectiveDirectory, { path: file.path });
const diff = { original: response.original ?? '', modified: response.modified ?? '' };
setDiff(effectiveDirectory, file.path, diff);
return { path: file.path, diff };
})
);
// Update state with all fetched diffs
setAllDiffs((prev) => {
const next = new Map(prev);
results.forEach((result) => {
if (result.status === 'fulfilled') {
next.set(result.value.path, result.value.diff);
}
});
return next;
});
// Clear loading state
setLoadingFiles((prev) => {
const next = new Set(prev);
filesToFetch.forEach((f) => next.delete(f.path));
return next;
});
};
fetchAllDiffs();
}, [effectiveDirectory, changedFiles, git, setDiff]); // eslint-disable-line react-hooks/exhaustive-deps
// Clear all diffs when directory changes
React.useEffect(() => {
setAllDiffs(new Map());
setLoadingFiles(new Set());
}, [effectiveDirectory]);
// Re-fetch stale diffs when status changes (don't clear - keep old ones visible while fetching)
React.useEffect(() => {
if (!effectiveDirectory || !lastStatusChange || changedFiles.length === 0) return;
const refetchStaleDiffs = async () => {
const dirState = useGitStore.getState().directories.get(effectiveDirectory);
if (!dirState) return;
// Find files that need refetching (stale cache or no cache)
const staleFiles = changedFiles.filter((file) => {
const cached = dirState.diffCache.get(file.path);
return !cached || cached.fetchedAt < lastStatusChange;
});
if (staleFiles.length === 0) return;
// Mark as loading
setLoadingFiles((prev) => {
const next = new Set(prev);
staleFiles.forEach((f) => next.add(f.path));
return next;
});
// Fetch in parallel
const results = await Promise.allSettled(
staleFiles.map(async (file) => {
const response = await git.getGitFileDiff(effectiveDirectory, { path: file.path });
const diff = { original: response.original ?? '', modified: response.modified ?? '' };
setDiff(effectiveDirectory, file.path, diff);
return { path: file.path, diff };
})
);
// Update diffs in place (don't clear old ones first)
setAllDiffs((prev) => {
const next = new Map(prev);
results.forEach((result) => {
if (result.status === 'fulfilled') {
next.set(result.value.path, result.value.diff);
}
});
// Remove diffs for files that no longer exist in changedFiles
for (const [filePath] of prev) {
if (!changedFiles.some((f) => f.path === filePath)) {
next.delete(filePath);
}
}
return next;
});
// Clear loading state
setLoadingFiles((prev) => {
const next = new Set(prev);
staleFiles.forEach((f) => next.delete(f.path));
return next;
});
};
refetchStaleDiffs();
}, [effectiveDirectory, lastStatusChange, changedFiles, git, setDiff]);
const handleSelectFile = React.useCallback((value: string) => {
setSelectedFile(value);
}, []);
const renderSideBySide = (currentLayoutForSelectedFile ?? 'side-by-side') === 'side-by-side';
const selectedCachedDiff = useGitStore(React.useCallback((state) => {
if (!effectiveDirectory || !selectedFile) return null;
return state.directories.get(effectiveDirectory)?.diffCache.get(selectedFile) ?? null;
}, [effectiveDirectory, selectedFile]));
const hasCurrentDiff = !!selectedCachedDiff;
const isCurrentFileLoading = !!selectedFile && !hasCurrentDiff;
// Render all diff viewers - they stay mounted
const renderAllDiffViewers = () => {
if (allDiffs.size === 0) return null;
if (!effectiveDirectory || changedFiles.length === 0) return null;
return Array.from(allDiffs.entries()).map(([filePath, diff]) => (
<SingleDiffViewer
key={filePath}
filePath={filePath}
diff={diff}
isVisible={filePath === selectedFile}
return changedFiles.map((file) => (
<DiffViewerEntry
key={file.path}
directory={effectiveDirectory}
filePath={file.path}
isVisible={file.path === selectedFile}
renderSideBySide={renderSideBySide}
wrapLines={diffWrapLines}
/>
@@ -528,8 +449,6 @@ export const DiffView: React.FC = () => {
);
}
const isCurrentFileLoading = selectedFile && loadingFiles.has(selectedFile);
const hasCurrentDiff = selectedFile && allDiffs.has(selectedFile);
return (
<div className="flex flex-1 min-h-0 overflow-hidden px-3 py-3 relative">
+157 -3
View File
@@ -1,24 +1,176 @@
import React, { useMemo } from 'react';
import React, { useMemo, useEffect, useRef } from 'react';
import { WorkerPoolContextProvider, useWorkerPool } from '@pierre/diffs/react';
import { parseDiffFromFile, type FileContents } from '@pierre/diffs';
import type { SupportedLanguages } from '@pierre/diffs';
import { useOptionalThemeSystem } from './useThemeSystem';
import { workerFactory } from '@/lib/diff/workerFactory';
import { ensureFlexokiThemesRegistered } from '@/lib/shiki/registerFlexokiThemes';
import { flexokiThemeNames } from '@/lib/shiki/flexokiThemes';
import { useGitStore } from '@/stores/useGitStore';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
// Only preload the most common languages - others load on demand
// Preload common languages for faster initial diff rendering
const PRELOAD_LANGS: SupportedLanguages[] = [
'typescript',
'javascript',
'tsx',
'jsx',
'json',
'html',
'css',
'markdown',
'yaml',
'python',
'rust',
'go',
'bash',
];
// Matches cache key logic in `packages/ui/src/components/views/PierreDiffViewer.tsx`
function getPierreCacheKey(fileName: string, original: string, modified: string): string {
const sampleOriginal = original.length > 100
? `${original.slice(0, 50)}${original.slice(-50)}`
: original;
const sampleModified = modified.length > 100
? `${modified.slice(0, 50)}${modified.slice(-50)}`
: modified;
return `${fileName}:${original.length}:${modified.length}:${sampleOriginal.length}:${sampleModified.length}`;
}
interface DiffWorkerProviderProps {
children: React.ReactNode;
}
type IdleDeadlineLike = { timeRemaining(): number; didTimeout: boolean };
function scheduleWarmupWork(cb: (deadline?: IdleDeadlineLike) => void): () => void {
if (typeof window === 'undefined') {
return () => {};
}
const id = window.setTimeout(() => cb(undefined), 0);
return () => window.clearTimeout(id);
}
// Component that warms up the worker pool and precomputes diff ASTs
const WorkerPoolWarmup: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const workerPool = useWorkerPool();
const activeDirectory = useGitStore((state) => state.activeDirectory);
const lastStatusChange = useGitStore((state) => {
if (!activeDirectory) return 0;
return state.directories.get(activeDirectory)?.lastStatusChange ?? 0;
});
const diffCacheSize = useGitStore((state) => {
if (!activeDirectory) return 0;
return state.directories.get(activeDirectory)?.diffCache.size ?? 0;
});
const didDummyWarmupRef = useRef(false);
const warmedStatusRef = useRef(new Map<string, number>());
useEffect(() => {
if (!workerPool || didDummyWarmupRef.current) return;
didDummyWarmupRef.current = true;
const dummyFile: FileContents = {
name: 'warmup.ts',
contents: 'const x = 1;',
lang: 'typescript',
cacheKey: 'warmup-file',
};
const dummyDiff = parseDiffFromFile(dummyFile, { ...dummyFile, contents: 'const x = 2;', cacheKey: 'warmup-file-2' });
const dummyInstance = {
onHighlightSuccess: () => {},
onHighlightError: () => {},
};
workerPool.highlightDiffAST(dummyInstance, dummyDiff);
}, [workerPool]);
useEffect(() => {
if (!workerPool || !activeDirectory) return;
if (!lastStatusChange || diffCacheSize === 0) return;
const dirState = useGitStore.getState().directories.get(activeDirectory);
if (!dirState || dirState.diffCache.size === 0) return;
const alreadyWarmedAt = warmedStatusRef.current.get(activeDirectory) ?? 0;
if (alreadyWarmedAt >= dirState.lastStatusChange) return;
// Only warm once per status-change tick
warmedStatusRef.current.set(activeDirectory, dirState.lastStatusChange);
let cancelled = false;
let cancelScheduled: (() => void) | null = null;
const entries = Array.from(dirState.diffCache.entries());
let index = 0;
const processChunk = (deadline?: IdleDeadlineLike) => {
if (cancelled) return;
const chunkStart = Date.now();
while (index < entries.length) {
if (deadline && deadline.timeRemaining() < 8) break;
if (!deadline && Date.now() - chunkStart > 8) break;
const [filePath, diff] = entries[index];
index += 1;
const language = getLanguageFromExtension(filePath) || 'text';
const cacheKey = getPierreCacheKey(filePath, diff.original, diff.modified);
const oldFile: FileContents = {
name: filePath,
contents: diff.original,
lang: language as FileContents['lang'],
cacheKey: `old-${cacheKey}`,
};
const newFile: FileContents = {
name: filePath,
contents: diff.modified,
lang: language as FileContents['lang'],
cacheKey: `new-${cacheKey}`,
};
const fileDiff = parseDiffFromFile(oldFile, newFile);
// Use a unique instance per request so Pierre doesn't ignore earlier results.
const instance = {
onHighlightSuccess: () => {},
onHighlightError: () => {},
};
workerPool.highlightDiffAST(instance, fileDiff);
}
if (index < entries.length) {
cancelScheduled = scheduleWarmupWork(processChunk);
}
};
// Kick off immediately, then continue in chunks.
processChunk(undefined);
if (index < entries.length) {
cancelScheduled = scheduleWarmupWork(processChunk);
}
return () => {
cancelled = true;
cancelScheduled?.();
};
}, [workerPool, activeDirectory, lastStatusChange, diffCacheSize]);
return <>{children}</>;
};
export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children }) => {
const themeSystem = useOptionalThemeSystem();
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
@@ -43,7 +195,9 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
}}
highlighterOptions={highlighterOptions}
>
{children}
<WorkerPoolWarmup>
{children}
</WorkerPoolWarmup>
</WorkerPoolContextProvider>
);
};
+131 -5
View File
@@ -7,10 +7,10 @@ import type {
GitIdentitySummary,
} from '@/lib/api/types';
const GIT_POLL_BASE_INTERVAL = 10000;
const GIT_POLL_MAX_INTERVAL = 20000;
const GIT_POLL_BASE_INTERVAL = 5000;
const GIT_POLL_MAX_INTERVAL = 10000;
const GIT_POLL_BACKOFF_STEP = 5000;
const LOG_STALE_THRESHOLD = 30000;
const LOG_STALE_THRESHOLD = 10000;
interface DirectoryGitState {
isGitRepo: boolean | null;
@@ -51,6 +51,7 @@ interface GitStore {
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number } | null;
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string }) => void;
clearDiffCache: (directory: string) => void;
fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
setLogMaxCount: (directory: string, maxCount: number) => void;
@@ -60,12 +61,19 @@ interface GitStore {
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
}
interface GitFileDiffResponse {
original: string;
modified: string;
path: string;
}
interface GitAPI {
checkIsGitRepository: (directory: string) => Promise<boolean>;
getGitStatus: (directory: string) => Promise<GitStatus>;
getGitBranches: (directory: string) => Promise<GitBranch>;
getGitLog: (directory: string, options?: { maxCount?: number }) => Promise<GitLogResponse>;
getCurrentGitIdentity: (directory: string) => Promise<GitIdentitySummary | null>;
getGitFileDiff: (directory: string, options: { path: string }) => Promise<GitFileDiffResponse>;
}
const createEmptyDirectoryState = (): DirectoryGitState => ({
@@ -132,6 +140,55 @@ const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | nu
return false;
};
const getChangedFilePaths = (oldStatus: GitStatus | null, newStatus: GitStatus | null): Set<string> => {
const changed = new Set<string>();
if (!newStatus) return changed;
const oldFiles = oldStatus?.files ?? [];
const newFiles = newStatus.files ?? [];
const oldFileMap = new Map(oldFiles.map((f) => [f.path, f] as const));
const newFileMap = new Map(newFiles.map((f) => [f.path, f] as const));
const allFilePaths = new Set<string>([...oldFileMap.keys(), ...newFileMap.keys()]);
for (const filePath of allFilePaths) {
const oldFile = oldFileMap.get(filePath);
const newFile = newFileMap.get(filePath);
// Added/removed/renamed
if (!oldFile || !newFile) {
changed.add(filePath);
continue;
}
// Index/worktree state changed (indicates actual content/state changed)
if (oldFile.index !== newFile.index || oldFile.working_dir !== newFile.working_dir) {
changed.add(filePath);
continue;
}
}
const oldStats = oldStatus?.diffStats ?? {};
const newStats = newStatus.diffStats ?? {};
const allStatPaths = new Set<string>([...Object.keys(oldStats), ...Object.keys(newStats)]);
for (const filePath of allStatPaths) {
const oldEntry = oldStats[filePath];
const newEntry = newStats[filePath];
if (!oldEntry || !newEntry) {
changed.add(filePath);
continue;
}
if (oldEntry.insertions !== newEntry.insertions || oldEntry.deletions !== newEntry.deletions) {
changed.add(filePath);
}
}
return changed;
};
export const useGitStore = create<GitStore>()(
devtools(
(set, get) => ({
@@ -198,13 +255,34 @@ export const useGitStore = create<GitStore>()(
const newDirectories = new Map(get().directories);
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
const changedPaths = getChangedFilePaths(currentDirState.status, newStatus);
const oldPaths = new Set((currentDirState.status?.files ?? []).map((f) => f.path));
const newPaths = new Set((newStatus.files ?? []).map((f) => f.path));
const nextDiffCache = new Map(currentDirState.diffCache);
// Drop cache for removed files
for (const oldPath of oldPaths) {
if (!newPaths.has(oldPath)) {
nextDiffCache.delete(oldPath);
}
}
// Drop cache for files whose state/content changed
for (const filePath of changedPaths) {
nextDiffCache.delete(filePath);
}
const hasFileContentChange = changedPaths.size > 0;
newDirectories.set(directory, {
...currentDirState,
isGitRepo: true,
status: newStatus,
diffCache: new Map(),
diffCache: nextDiffCache,
lastStatusFetch: Date.now(),
lastStatusChange: Date.now(),
lastStatusChange: hasFileContentChange ? Date.now() : currentDirState.lastStatusChange,
});
set({ directories: newDirectories });
} else {
@@ -314,6 +392,9 @@ export const useGitStore = create<GitStore>()(
}
await get().fetchIdentity(directory, git);
// Pre-fetch all diffs so they're ready when user opens Diff tab
await get().fetchAllDiffs(directory, git);
},
getDiff: (directory, filePath) => {
@@ -339,6 +420,49 @@ export const useGitStore = create<GitStore>()(
}
},
fetchAllDiffs: async (directory, git) => {
const dirState = get().directories.get(directory);
if (!dirState?.status?.files || dirState.status.files.length === 0) return;
const files = dirState.status.files;
// Find files that need fetching (no cache)
const filesToFetch = files.filter((file) => !dirState.diffCache.has(file.path));
if (filesToFetch.length === 0) return;
// Fetch all diffs in parallel
const results = await Promise.allSettled(
filesToFetch.map(async (file) => {
const response = await git.getGitFileDiff(directory, { path: file.path });
return {
path: file.path,
diff: { original: response.original ?? '', modified: response.modified ?? '' }
};
})
);
// Update diff cache with results
const newDirectories = new Map(get().directories);
const currentDirState = newDirectories.get(directory);
if (!currentDirState) return;
const newDiffCache = new Map(currentDirState.diffCache);
const now = Date.now();
results.forEach((result) => {
if (result.status === 'fulfilled') {
newDiffCache.set(result.value.path, {
...result.value.diff,
fetchedAt: now
});
}
});
newDirectories.set(directory, { ...currentDirState, diffCache: newDiffCache });
set({ directories: newDirectories });
},
setLogMaxCount: (directory, maxCount) => {
const newDirectories = new Map(get().directories);
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
@@ -368,6 +492,8 @@ export const useGitStore = create<GitStore>()(
const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true });
if (statusChanged) {
await get().fetchLog(activeDirectory, git);
// Pre-fetch all diffs so they're ready when user opens Diff tab
await get().fetchAllDiffs(activeDirectory, git);
// Reset to base interval on changes
set({ currentPollInterval: GIT_POLL_BASE_INTERVAL });
} else {
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB