feat: optimize diff loading performance with background pre-fetching
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 +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 |
Reference in New Issue
Block a user