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)
|
- 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
|
- 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
|
## [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 useEffectiveDirectory = () => {
|
||||||
const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore();
|
const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore();
|
||||||
const { currentDirectory: fallbackDirectory } = useDirectoryStore();
|
const { currentDirectory: fallbackDirectory } = useDirectoryStore();
|
||||||
@@ -254,11 +293,9 @@ export const DiffView: React.FC = () => {
|
|||||||
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
|
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
|
||||||
const status = useGitStatus(effectiveDirectory ?? null);
|
const status = useGitStatus(effectiveDirectory ?? null);
|
||||||
const isLoadingStatus = useGitStore((state) => state.isLoadingStatus);
|
const isLoadingStatus = useGitStore((state) => state.isLoadingStatus);
|
||||||
const { setActiveDirectory, fetchStatus, setDiff } = useGitStore();
|
const { setActiveDirectory, fetchStatus } = useGitStore();
|
||||||
|
|
||||||
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
|
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 pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
|
||||||
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
|
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
|
||||||
@@ -267,10 +304,6 @@ export const DiffView: React.FC = () => {
|
|||||||
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
|
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
|
||||||
const diffWrapLines = useUIStore((state) => state.diffWrapLines);
|
const diffWrapLines = useUIStore((state) => state.diffWrapLines);
|
||||||
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
|
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(() => {
|
const changedFiles: FileEntry[] = React.useMemo(() => {
|
||||||
if (!status?.files) return [];
|
if (!status?.files) return [];
|
||||||
@@ -352,142 +385,30 @@ export const DiffView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [changedFiles, selectedFile]);
|
}, [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) => {
|
const handleSelectFile = React.useCallback((value: string) => {
|
||||||
setSelectedFile(value);
|
setSelectedFile(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderSideBySide = (currentLayoutForSelectedFile ?? 'side-by-side') === 'side-by-side';
|
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
|
// Render all diff viewers - they stay mounted
|
||||||
const renderAllDiffViewers = () => {
|
const renderAllDiffViewers = () => {
|
||||||
if (allDiffs.size === 0) return null;
|
if (!effectiveDirectory || changedFiles.length === 0) return null;
|
||||||
|
|
||||||
return Array.from(allDiffs.entries()).map(([filePath, diff]) => (
|
return changedFiles.map((file) => (
|
||||||
<SingleDiffViewer
|
<DiffViewerEntry
|
||||||
key={filePath}
|
key={file.path}
|
||||||
filePath={filePath}
|
directory={effectiveDirectory}
|
||||||
diff={diff}
|
filePath={file.path}
|
||||||
isVisible={filePath === selectedFile}
|
isVisible={file.path === selectedFile}
|
||||||
renderSideBySide={renderSideBySide}
|
renderSideBySide={renderSideBySide}
|
||||||
wrapLines={diffWrapLines}
|
wrapLines={diffWrapLines}
|
||||||
/>
|
/>
|
||||||
@@ -528,8 +449,6 @@ export const DiffView: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCurrentFileLoading = selectedFile && loadingFiles.has(selectedFile);
|
|
||||||
const hasCurrentDiff = selectedFile && allDiffs.has(selectedFile);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden px-3 py-3 relative">
|
<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 { WorkerPoolContextProvider, useWorkerPool } from '@pierre/diffs/react';
|
||||||
|
import { parseDiffFromFile, type FileContents } from '@pierre/diffs';
|
||||||
import type { SupportedLanguages } from '@pierre/diffs';
|
import type { SupportedLanguages } from '@pierre/diffs';
|
||||||
|
|
||||||
import { useOptionalThemeSystem } from './useThemeSystem';
|
import { useOptionalThemeSystem } from './useThemeSystem';
|
||||||
import { workerFactory } from '@/lib/diff/workerFactory';
|
import { workerFactory } from '@/lib/diff/workerFactory';
|
||||||
import { ensureFlexokiThemesRegistered } from '@/lib/shiki/registerFlexokiThemes';
|
import { ensureFlexokiThemesRegistered } from '@/lib/shiki/registerFlexokiThemes';
|
||||||
import { flexokiThemeNames } from '@/lib/shiki/flexokiThemes';
|
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[] = [
|
const PRELOAD_LANGS: SupportedLanguages[] = [
|
||||||
'typescript',
|
'typescript',
|
||||||
'javascript',
|
'javascript',
|
||||||
'tsx',
|
'tsx',
|
||||||
|
'jsx',
|
||||||
'json',
|
'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 {
|
interface DiffWorkerProviderProps {
|
||||||
children: React.ReactNode;
|
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 }) => {
|
export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children }) => {
|
||||||
const themeSystem = useOptionalThemeSystem();
|
const themeSystem = useOptionalThemeSystem();
|
||||||
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
|
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
|
||||||
@@ -43,7 +195,9 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
|
|||||||
}}
|
}}
|
||||||
highlighterOptions={highlighterOptions}
|
highlighterOptions={highlighterOptions}
|
||||||
>
|
>
|
||||||
{children}
|
<WorkerPoolWarmup>
|
||||||
|
{children}
|
||||||
|
</WorkerPoolWarmup>
|
||||||
</WorkerPoolContextProvider>
|
</WorkerPoolContextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import type {
|
|||||||
GitIdentitySummary,
|
GitIdentitySummary,
|
||||||
} from '@/lib/api/types';
|
} from '@/lib/api/types';
|
||||||
|
|
||||||
const GIT_POLL_BASE_INTERVAL = 10000;
|
const GIT_POLL_BASE_INTERVAL = 5000;
|
||||||
const GIT_POLL_MAX_INTERVAL = 20000;
|
const GIT_POLL_MAX_INTERVAL = 10000;
|
||||||
const GIT_POLL_BACKOFF_STEP = 5000;
|
const GIT_POLL_BACKOFF_STEP = 5000;
|
||||||
const LOG_STALE_THRESHOLD = 30000;
|
const LOG_STALE_THRESHOLD = 10000;
|
||||||
|
|
||||||
interface DirectoryGitState {
|
interface DirectoryGitState {
|
||||||
isGitRepo: boolean | null;
|
isGitRepo: boolean | null;
|
||||||
@@ -51,6 +51,7 @@ interface GitStore {
|
|||||||
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number } | null;
|
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number } | null;
|
||||||
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string }) => void;
|
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string }) => void;
|
||||||
clearDiffCache: (directory: string) => void;
|
clearDiffCache: (directory: string) => void;
|
||||||
|
fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
|
||||||
|
|
||||||
setLogMaxCount: (directory: string, maxCount: number) => void;
|
setLogMaxCount: (directory: string, maxCount: number) => void;
|
||||||
|
|
||||||
@@ -60,12 +61,19 @@ interface GitStore {
|
|||||||
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
|
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitFileDiffResponse {
|
||||||
|
original: string;
|
||||||
|
modified: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface GitAPI {
|
interface GitAPI {
|
||||||
checkIsGitRepository: (directory: string) => Promise<boolean>;
|
checkIsGitRepository: (directory: string) => Promise<boolean>;
|
||||||
getGitStatus: (directory: string) => Promise<GitStatus>;
|
getGitStatus: (directory: string) => Promise<GitStatus>;
|
||||||
getGitBranches: (directory: string) => Promise<GitBranch>;
|
getGitBranches: (directory: string) => Promise<GitBranch>;
|
||||||
getGitLog: (directory: string, options?: { maxCount?: number }) => Promise<GitLogResponse>;
|
getGitLog: (directory: string, options?: { maxCount?: number }) => Promise<GitLogResponse>;
|
||||||
getCurrentGitIdentity: (directory: string) => Promise<GitIdentitySummary | null>;
|
getCurrentGitIdentity: (directory: string) => Promise<GitIdentitySummary | null>;
|
||||||
|
getGitFileDiff: (directory: string, options: { path: string }) => Promise<GitFileDiffResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createEmptyDirectoryState = (): DirectoryGitState => ({
|
const createEmptyDirectoryState = (): DirectoryGitState => ({
|
||||||
@@ -132,6 +140,55 @@ const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | nu
|
|||||||
return false;
|
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>()(
|
export const useGitStore = create<GitStore>()(
|
||||||
devtools(
|
devtools(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -198,13 +255,34 @@ export const useGitStore = create<GitStore>()(
|
|||||||
const newDirectories = new Map(get().directories);
|
const newDirectories = new Map(get().directories);
|
||||||
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
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, {
|
newDirectories.set(directory, {
|
||||||
...currentDirState,
|
...currentDirState,
|
||||||
isGitRepo: true,
|
isGitRepo: true,
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
diffCache: new Map(),
|
diffCache: nextDiffCache,
|
||||||
lastStatusFetch: Date.now(),
|
lastStatusFetch: Date.now(),
|
||||||
lastStatusChange: Date.now(),
|
lastStatusChange: hasFileContentChange ? Date.now() : currentDirState.lastStatusChange,
|
||||||
});
|
});
|
||||||
set({ directories: newDirectories });
|
set({ directories: newDirectories });
|
||||||
} else {
|
} else {
|
||||||
@@ -314,6 +392,9 @@ export const useGitStore = create<GitStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await get().fetchIdentity(directory, git);
|
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) => {
|
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) => {
|
setLogMaxCount: (directory, maxCount) => {
|
||||||
const newDirectories = new Map(get().directories);
|
const newDirectories = new Map(get().directories);
|
||||||
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||||
@@ -368,6 +492,8 @@ export const useGitStore = create<GitStore>()(
|
|||||||
const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true });
|
const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true });
|
||||||
if (statusChanged) {
|
if (statusChanged) {
|
||||||
await get().fetchLog(activeDirectory, git);
|
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
|
// Reset to base interval on changes
|
||||||
set({ currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
set({ currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
||||||
} else {
|
} 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