Improve diff rendering pipeline
This commit is contained in:
@@ -29,11 +29,15 @@ import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n/store';
|
||||
import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
|
||||
import type { FileDiffMetadata } from '@pierre/diffs';
|
||||
|
||||
// Minimum width for side-by-side diff view (px)
|
||||
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
|
||||
const DIFF_REQUEST_TIMEOUT_MS = 15000;
|
||||
const LARGE_DIFF_CHANGED_LINES = 500;
|
||||
const STACKED_DIFF_MOUNT_MARGIN = 300;
|
||||
const FULL_CONTEXT_DIFF_LINES = 1_000_000;
|
||||
|
||||
// Perf: limit concurrent expanded diffs in stacked view.
|
||||
// Expanding many diffs mounts many Pierre instances + lots of DOM.
|
||||
@@ -50,7 +54,7 @@ type FileEntry = GitStatus['files'][number] & {
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
type DiffData = { original: string; modified: string; isBinary?: boolean };
|
||||
type DiffData = { original: string; modified: string; isBinary?: boolean; patch?: string; fileDiff?: FileDiffMetadata };
|
||||
type DiffScope = 'all' | 'staged' | 'working';
|
||||
|
||||
const BinaryDiffPlaceholder = React.memo(() => {
|
||||
@@ -156,6 +160,22 @@ const getFirstVisibleModifiedLineFromPatch = (patch: string): number | null => {
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const isBinaryPatch = (patch: string): boolean =>
|
||||
/^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
|
||||
|
||||
const createTextDiffDataFromPatch = (filePath: string, patch: string): DiffData => {
|
||||
if (isBinaryPatch(patch)) {
|
||||
return { original: '', modified: '', isBinary: true, patch };
|
||||
}
|
||||
|
||||
return {
|
||||
original: '',
|
||||
modified: '',
|
||||
patch,
|
||||
fileDiff: fileDiffFromPatch(filePath, patch),
|
||||
};
|
||||
};
|
||||
|
||||
const formatDiffTotals = (insertions?: number, deletions?: number) => {
|
||||
const added = insertions ?? 0;
|
||||
const removed = deletions ?? 0;
|
||||
@@ -409,6 +429,7 @@ const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
||||
<PierreDiffViewer
|
||||
original={diff.original}
|
||||
modified={diff.modified}
|
||||
fileDiff={diff.fileDiff}
|
||||
language={language}
|
||||
fileName={filePath}
|
||||
renderSideBySide={renderSideBySide}
|
||||
@@ -424,14 +445,12 @@ interface MultiFileDiffEntryProps {
|
||||
file: FileEntry;
|
||||
layout: 'inline' | 'side-by-side';
|
||||
wrapLines: boolean;
|
||||
scrollRootRef: React.RefObject<HTMLElement | null>;
|
||||
isSelected: boolean;
|
||||
isExpanded: boolean;
|
||||
isMounted: boolean;
|
||||
onSelect: (path: string) => void;
|
||||
onExpandedChange: (path: string, expanded: boolean) => void;
|
||||
registerSectionRef: (path: string, node: HTMLDivElement | null) => void;
|
||||
/** Start collapsed to reduce memory with many files */
|
||||
defaultCollapsed?: boolean;
|
||||
expandRequestPath?: string | null;
|
||||
expandRequestNonce?: number;
|
||||
showOpenInEditorAction?: boolean;
|
||||
isOpeningInEditor?: boolean;
|
||||
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
|
||||
@@ -444,13 +463,12 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
file,
|
||||
layout,
|
||||
wrapLines,
|
||||
scrollRootRef,
|
||||
isSelected,
|
||||
isExpanded,
|
||||
isMounted,
|
||||
onSelect,
|
||||
onExpandedChange,
|
||||
registerSectionRef,
|
||||
defaultCollapsed = false,
|
||||
expandRequestPath = null,
|
||||
expandRequestNonce = 0,
|
||||
showOpenInEditorAction = false,
|
||||
isOpeningInEditor = false,
|
||||
onOpenInEditor,
|
||||
@@ -467,12 +485,11 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
const setDiff = useGitStore((state) => state.setDiff);
|
||||
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
|
||||
|
||||
const [isExpanded, setIsExpanded] = React.useState(!defaultCollapsed);
|
||||
const [hasBeenVisible, setHasBeenVisible] = React.useState(false);
|
||||
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
||||
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
|
||||
const [localDiffData, setLocalDiffData] = React.useState<DiffData | null>(null);
|
||||
const [stagedDiffData, setStagedDiffData] = React.useState<DiffData | null>(null);
|
||||
const lastDiffRequestRef = React.useRef<string | null>(null);
|
||||
const sectionRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -482,9 +499,9 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
|
||||
const diffData = React.useMemo<DiffData | null>(() => {
|
||||
if (staged) return stagedDiffData;
|
||||
if (!cachedDiff) return null;
|
||||
if (!cachedDiff) return localDiffData;
|
||||
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary };
|
||||
}, [cachedDiff, staged, stagedDiffData]);
|
||||
}, [cachedDiff, localDiffData, staged, stagedDiffData]);
|
||||
|
||||
const setSectionRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||
sectionRef.current = node;
|
||||
@@ -492,61 +509,26 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
}, [file.path, registerSectionRef]);
|
||||
|
||||
const handleOpenChange = React.useCallback((open: boolean) => {
|
||||
setIsExpanded(open);
|
||||
if (open) {
|
||||
setHasBeenVisible(true);
|
||||
}
|
||||
}, []);
|
||||
onExpandedChange(file.path, open);
|
||||
}, [file.path, onExpandedChange]);
|
||||
|
||||
const handleSelect = React.useCallback(() => {
|
||||
onSelect(file.path);
|
||||
}, [file.path, onSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded || hasBeenVisible) return;
|
||||
const target = sectionRef.current;
|
||||
if (!target) return;
|
||||
|
||||
if (!scrollRootRef.current || typeof IntersectionObserver === 'undefined') {
|
||||
setHasBeenVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setHasBeenVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ root: scrollRootRef.current, rootMargin: '200px 0px', threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasBeenVisible, isExpanded, scrollRootRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expandRequestNonce <= 0 || expandRequestPath !== file.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExpanded(true);
|
||||
setHasBeenVisible(true);
|
||||
}, [expandRequestNonce, expandRequestPath, file.path]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!staged) {
|
||||
return;
|
||||
setLocalDiffData(null);
|
||||
} else {
|
||||
setStagedDiffData(null);
|
||||
}
|
||||
|
||||
setStagedDiffData(null);
|
||||
setDiffLoadError(null);
|
||||
lastDiffRequestRef.current = null;
|
||||
}, [staged, stagedRevision]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded || !hasBeenVisible) return;
|
||||
if (!isExpanded || !isMounted) return;
|
||||
if (!directory || diffData) {
|
||||
lastDiffRequestRef.current = null;
|
||||
setIsLoading(false);
|
||||
@@ -562,7 +544,9 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
setIsLoading(true);
|
||||
|
||||
let cancelled = false;
|
||||
const fetchPromise = git.getGitFileDiff(directory, { path: file.path, staged });
|
||||
const fetchPromise = isImageFile(file.path)
|
||||
? git.getGitFileDiff(directory, { path: file.path, staged })
|
||||
: git.getGitDiff(directory, { path: file.path, staged, contextLines: FULL_CONTEXT_DIFF_LINES });
|
||||
const timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
@@ -572,15 +556,24 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const nextDiff = {
|
||||
original: response.original ?? '',
|
||||
modified: response.modified ?? '',
|
||||
isBinary: response.isBinary,
|
||||
};
|
||||
if (staged) {
|
||||
setStagedDiffData(nextDiff);
|
||||
if ('diff' in response) {
|
||||
const nextDiff = createTextDiffDataFromPatch(file.path, response.diff);
|
||||
if (staged) {
|
||||
setStagedDiffData(nextDiff);
|
||||
} else {
|
||||
setLocalDiffData(nextDiff);
|
||||
}
|
||||
} else {
|
||||
setDiff(directory, file.path, nextDiff);
|
||||
const nextDiff = {
|
||||
original: response.original ?? '',
|
||||
modified: response.modified ?? '',
|
||||
isBinary: response.isBinary,
|
||||
};
|
||||
if (staged) {
|
||||
setStagedDiffData(nextDiff);
|
||||
} else {
|
||||
setDiff(directory, file.path, nextDiff);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
@@ -597,7 +590,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
lastDiffRequestRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [directory, diffData, diffRetryNonce, file.path, git, hasBeenVisible, isExpanded, setDiff, staged, stagedRevision]);
|
||||
}, [directory, diffData, diffRetryNonce, file.path, git, isExpanded, isMounted, setDiff, staged, stagedRevision]);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
handleOpenChange(!isExpanded);
|
||||
@@ -709,10 +702,13 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="relative bg-background overflow-hidden">
|
||||
{!isMounted && !diffLoadError ? (
|
||||
<div className="h-40 border border-border/40 bg-background/40" />
|
||||
) : null}
|
||||
{diffLoadError ? (
|
||||
<div className="flex flex-col items-center gap-2 px-4 py-8 text-sm text-muted-foreground">
|
||||
<div className="typography-ui-label font-semibold text-foreground">
|
||||
Failed to load diff
|
||||
{t('diffView.state.failedToLoadDiff')}
|
||||
</div>
|
||||
<div className="typography-meta text-muted-foreground max-w-[32rem] text-center">
|
||||
{diffLoadError}
|
||||
@@ -722,34 +718,34 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
className="typography-ui-label text-primary hover:underline"
|
||||
onClick={() => setDiffRetryNonce((nonce) => nonce + 1)}
|
||||
>
|
||||
Retry
|
||||
{t('diffView.actions.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading && !diffData && !diffLoadError ? (
|
||||
{isMounted && isLoading && !diffData && !diffLoadError ? (
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
Loading diff…
|
||||
{t('diffView.state.loadingDiff')}
|
||||
</div>
|
||||
) : null}
|
||||
{diffData && !forceRenderLarge && (file.insertions + file.deletions) > LARGE_DIFF_CHANGED_LINES ? (
|
||||
{isMounted && diffData && !forceRenderLarge && (file.insertions + file.deletions) > LARGE_DIFF_CHANGED_LINES ? (
|
||||
<div className="flex flex-col items-center gap-2 px-4 py-8 text-sm text-muted-foreground">
|
||||
<div className="typography-ui-label font-semibold text-foreground">
|
||||
Large diff ({file.insertions + file.deletions} changed lines)
|
||||
{t('diffView.state.largeDiff', { count: file.insertions + file.deletions })}
|
||||
</div>
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
Rendering may be slow. You can still view the diff by clicking below.
|
||||
{t('diffView.state.largeDiffDescription')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="typography-ui-label text-primary hover:underline"
|
||||
onClick={() => setForceRenderLarge(true)}
|
||||
>
|
||||
Render anyway
|
||||
{t('diffView.actions.renderAnyway')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{diffData && (forceRenderLarge || (file.insertions + file.deletions) <= LARGE_DIFF_CHANGED_LINES) ? (
|
||||
{isMounted && diffData && (forceRenderLarge || (file.insertions + file.deletions) <= LARGE_DIFF_CHANGED_LINES) ? (
|
||||
<InlineDiffViewer
|
||||
filePath={file.path}
|
||||
diff={diffData}
|
||||
@@ -805,9 +801,9 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
|
||||
const [selectedFileStaged, setSelectedFileStaged] = React.useState(false);
|
||||
const [selectedStagedDiffData, setSelectedStagedDiffData] = React.useState<DiffData | null>(null);
|
||||
const [stackedExpandTarget, setStackedExpandTarget] = React.useState<string | null>(null);
|
||||
const [stackedExpandRequestNonce, setStackedExpandRequestNonce] = React.useState(0);
|
||||
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState<string | null>(null);
|
||||
const [expandedFiles, setExpandedFiles] = React.useState<Set<string>>(() => new Set());
|
||||
const [mountedStackedFiles, setMountedStackedFiles] = React.useState<Set<string>>(() => new Set());
|
||||
|
||||
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
|
||||
const pendingDiffStaged = useUIStore((state) => state.pendingDiffStaged);
|
||||
@@ -829,101 +825,28 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const pendingScrollTargetRef = React.useRef<string | null>(null);
|
||||
const pendingScrollFrameRef = React.useRef<number | null>(null);
|
||||
const shouldPinAfterAlignRef = React.useRef(false);
|
||||
const visibleSyncFrameRef = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pinSelectedFileHeaderToTopOnNavigate || !pinnedStackedTarget) {
|
||||
return;
|
||||
const cancelPendingScrollAlignment = React.useCallback(() => {
|
||||
pendingScrollTargetRef.current = null;
|
||||
shouldPinAfterAlignRef.current = false;
|
||||
setPinnedStackedTarget(null);
|
||||
if (pendingScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(pendingScrollFrameRef.current);
|
||||
pendingScrollFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollRoot = diffScrollRef.current;
|
||||
if (!scrollRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId: number | null = null;
|
||||
let cancelled = false;
|
||||
let stableFrames = 0;
|
||||
const stopAt = Date.now() + 1200;
|
||||
let ignoreNextScrollEvents = 0;
|
||||
|
||||
const stop = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
const expandStackedFile = React.useCallback((path: string) => {
|
||||
setExpandedFiles((previous) => {
|
||||
if (previous.has(path)) {
|
||||
return previous;
|
||||
}
|
||||
cancelled = true;
|
||||
setPinnedStackedTarget(null);
|
||||
};
|
||||
|
||||
const cancelOnUserInput = () => {
|
||||
stop();
|
||||
};
|
||||
|
||||
const cancelOnScroll = () => {
|
||||
if (ignoreNextScrollEvents > 0) {
|
||||
ignoreNextScrollEvents -= 1;
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
};
|
||||
|
||||
window.addEventListener('wheel', cancelOnUserInput, { passive: true, capture: true });
|
||||
window.addEventListener('touchstart', cancelOnUserInput, { passive: true, capture: true });
|
||||
window.addEventListener('pointerdown', cancelOnUserInput, { capture: true });
|
||||
window.addEventListener('keydown', cancelOnUserInput, { capture: true });
|
||||
scrollRoot.addEventListener('scroll', cancelOnScroll, { passive: true });
|
||||
|
||||
const tick = () => {
|
||||
if (cancelled || Date.now() > stopAt) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentScrollRoot = diffScrollRef.current;
|
||||
const node = fileSectionRefs.current.get(pinnedStackedTarget);
|
||||
if (!currentScrollRoot || !node) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
const rootRect = currentScrollRoot.getBoundingClientRect();
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
const delta = nodeRect.top - rootRect.top;
|
||||
|
||||
if (Math.abs(delta) <= 1) {
|
||||
stableFrames += 1;
|
||||
if (stableFrames >= 2) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
stableFrames = 0;
|
||||
const maxTop = Math.max(0, currentScrollRoot.scrollHeight - currentScrollRoot.clientHeight);
|
||||
const nextTop = Math.min(maxTop, Math.max(0, currentScrollRoot.scrollTop + delta));
|
||||
if (Math.abs(nextTop - currentScrollRoot.scrollTop) <= 0.5) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
ignoreNextScrollEvents += 1;
|
||||
currentScrollRoot.scrollTop = nextTop;
|
||||
}
|
||||
|
||||
rafId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafId = window.requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
window.removeEventListener('wheel', cancelOnUserInput, true);
|
||||
window.removeEventListener('touchstart', cancelOnUserInput, true);
|
||||
window.removeEventListener('pointerdown', cancelOnUserInput, true);
|
||||
window.removeEventListener('keydown', cancelOnUserInput, true);
|
||||
scrollRoot.removeEventListener('scroll', cancelOnScroll);
|
||||
};
|
||||
}, [pinSelectedFileHeaderToTopOnNavigate, pinnedStackedTarget]);
|
||||
const next = new Set(previous);
|
||||
next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const changedFiles: FileEntry[] = React.useMemo(() => {
|
||||
if (!status?.files) return [];
|
||||
@@ -950,6 +873,74 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
return changedFiles.find((file) => file.path === selectedFile) ?? null;
|
||||
}, [changedFiles, selectedFile]);
|
||||
|
||||
const changedFilePathsKey = React.useMemo(
|
||||
() => changedFiles.map((file) => file.path).join('\0'),
|
||||
[changedFiles],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const paths = changedFilePathsKey ? changedFilePathsKey.split('\0') : [];
|
||||
const defaultExpandedCount = stackedDefaultCollapsedAll
|
||||
? 0
|
||||
: getStackedViewDefaultExpandedCount(paths.length);
|
||||
const defaultExpanded = new Set(paths.slice(0, defaultExpandedCount));
|
||||
setExpandedFiles(defaultExpanded);
|
||||
setMountedStackedFiles(new Set());
|
||||
}, [changedFilePathsKey, stackedDefaultCollapsedAll]);
|
||||
|
||||
const syncVisibleStackedFiles = React.useCallback(() => {
|
||||
visibleSyncFrameRef.current = null;
|
||||
const scrollRoot = diffScrollRef.current;
|
||||
if (!scrollRoot) return;
|
||||
|
||||
const rootRect = scrollRoot.getBoundingClientRect();
|
||||
const top = rootRect.top - STACKED_DIFF_MOUNT_MARGIN;
|
||||
const bottom = rootRect.bottom + STACKED_DIFF_MOUNT_MARGIN;
|
||||
const next: Record<string, boolean> = {};
|
||||
|
||||
for (const [path, node] of fileSectionRefs.current) {
|
||||
if (!node || !expandedFiles.has(path)) continue;
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (rect.bottom < top || rect.top > bottom) continue;
|
||||
next[path] = true;
|
||||
}
|
||||
|
||||
setMountedStackedFiles((previous) => {
|
||||
let changed = false;
|
||||
const mounted = new Set(previous);
|
||||
for (const path of Object.keys(next)) {
|
||||
if (mounted.has(path)) continue;
|
||||
mounted.add(path);
|
||||
changed = true;
|
||||
}
|
||||
return changed ? mounted : previous;
|
||||
});
|
||||
}, [expandedFiles]);
|
||||
|
||||
const queueVisibleStackedFilesSync = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (visibleSyncFrameRef.current !== null) return;
|
||||
visibleSyncFrameRef.current = window.requestAnimationFrame(syncVisibleStackedFiles);
|
||||
}, [syncVisibleStackedFiles]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const scrollRoot = diffScrollRef.current;
|
||||
if (!scrollRoot) return;
|
||||
|
||||
queueVisibleStackedFilesSync();
|
||||
scrollRoot.addEventListener('scroll', queueVisibleStackedFilesSync, { passive: true });
|
||||
window.addEventListener('resize', queueVisibleStackedFilesSync);
|
||||
|
||||
return () => {
|
||||
scrollRoot.removeEventListener('scroll', queueVisibleStackedFilesSync);
|
||||
window.removeEventListener('resize', queueVisibleStackedFilesSync);
|
||||
if (visibleSyncFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(visibleSyncFrameRef.current);
|
||||
visibleSyncFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [changedFiles, expandedFiles, queueVisibleStackedFilesSync]);
|
||||
|
||||
const getLayoutForFile = React.useCallback((file: FileEntry): 'inline' | 'side-by-side' => {
|
||||
const override = diffFileLayout[file.path];
|
||||
if (override) return override;
|
||||
@@ -1009,10 +1000,9 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
setPendingDiffFile(null);
|
||||
shouldPinAfterAlignRef.current = true;
|
||||
pendingScrollTargetRef.current = pendingDiffFile;
|
||||
setStackedExpandTarget(pendingDiffFile);
|
||||
setStackedExpandRequestNonce((nonce) => nonce + 1);
|
||||
expandStackedFile(pendingDiffFile);
|
||||
}
|
||||
}, [diffScope, pendingDiffFile, pendingDiffStaged, setPendingDiffFile]);
|
||||
}, [diffScope, expandStackedFile, pendingDiffFile, pendingDiffStaged, setPendingDiffFile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (diffScope === 'all') {
|
||||
@@ -1030,9 +1020,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
|
||||
shouldPinAfterAlignRef.current = true;
|
||||
pendingScrollTargetRef.current = normalizedTarget;
|
||||
setStackedExpandTarget(normalizedTarget);
|
||||
setStackedExpandRequestNonce((nonce) => nonce + 1);
|
||||
}, [diffScope, targetFilePath]);
|
||||
expandStackedFile(normalizedTarget);
|
||||
}, [diffScope, expandStackedFile, targetFilePath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeDiffStaged) {
|
||||
@@ -1066,37 +1055,60 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
} else {
|
||||
map.delete(path);
|
||||
}
|
||||
}, []);
|
||||
queueVisibleStackedFilesSync();
|
||||
}, [queueVisibleStackedFilesSync]);
|
||||
|
||||
type ScrollToFileResult = {
|
||||
ok: boolean;
|
||||
aligned: boolean;
|
||||
didMove: boolean;
|
||||
atScrollLimit: boolean;
|
||||
delta: number;
|
||||
};
|
||||
const handleStackedEntryExpandedChange = React.useCallback((path: string, expanded: boolean) => {
|
||||
cancelPendingScrollAlignment();
|
||||
setExpandedFiles((previous) => {
|
||||
const hasPath = previous.has(path);
|
||||
if (expanded === hasPath) {
|
||||
return previous;
|
||||
}
|
||||
const next = new Set(previous);
|
||||
if (expanded) {
|
||||
next.add(path);
|
||||
} else {
|
||||
next.delete(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (!expanded) {
|
||||
setMountedStackedFiles((previous) => {
|
||||
if (!previous.has(path)) return previous;
|
||||
const next = new Set(previous);
|
||||
next.delete(path);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
queueVisibleStackedFilesSync();
|
||||
}, [cancelPendingScrollAlignment, queueVisibleStackedFilesSync]);
|
||||
|
||||
const scrollToFile = React.useCallback((path: string): ScrollToFileResult => {
|
||||
const handleExpandOrCollapseAll = React.useCallback(() => {
|
||||
cancelPendingScrollAlignment();
|
||||
setExpandedFiles((previous) => {
|
||||
if (previous.size > 0) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(changedFiles.map((file) => file.path));
|
||||
});
|
||||
setMountedStackedFiles(new Set());
|
||||
queueVisibleStackedFilesSync();
|
||||
}, [cancelPendingScrollAlignment, changedFiles, queueVisibleStackedFilesSync]);
|
||||
|
||||
const scrollToFile = React.useCallback((path: string): boolean => {
|
||||
const node = fileSectionRefs.current.get(path);
|
||||
const scrollRoot = diffScrollRef.current;
|
||||
if (!node || !scrollRoot) {
|
||||
return { ok: false, aligned: false, didMove: false, atScrollLimit: false, delta: 0 };
|
||||
return false;
|
||||
}
|
||||
|
||||
const rootRect = scrollRoot.getBoundingClientRect();
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
const delta = nodeRect.top - rootRect.top;
|
||||
|
||||
const maxTop = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight);
|
||||
const desiredTop = scrollRoot.scrollTop + delta;
|
||||
const nextTop = Math.min(maxTop, Math.max(0, desiredTop));
|
||||
const didMove = Math.abs(nextTop - scrollRoot.scrollTop) > 0.5;
|
||||
scrollRoot.scrollTop = nextTop;
|
||||
|
||||
const aligned = Math.abs(delta) <= 1;
|
||||
const atScrollLimit = nextTop <= 0.5 || nextTop >= maxTop - 0.5;
|
||||
|
||||
return { ok: true, aligned, didMove, atScrollLimit, delta };
|
||||
scrollRoot.scrollTop = Math.min(maxTop, Math.max(0, scrollRoot.scrollTop + delta));
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -1104,64 +1116,24 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
if (!target) return;
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 120;
|
||||
const maxAttempts = 20;
|
||||
let cancelled = false;
|
||||
let ignoreNextScrollEvents = 0;
|
||||
let didRemoveListeners = false;
|
||||
let stallFrames = 0;
|
||||
const stopAt = Date.now() + 2000;
|
||||
|
||||
const removeListeners = () => {
|
||||
if (didRemoveListeners) {
|
||||
return;
|
||||
}
|
||||
didRemoveListeners = true;
|
||||
window.removeEventListener('wheel', cancelOnUserInput, true);
|
||||
window.removeEventListener('touchstart', cancelOnUserInput, true);
|
||||
window.removeEventListener('pointerdown', cancelOnUserInput, true);
|
||||
window.removeEventListener('keydown', cancelOnUserInput, true);
|
||||
scrollRoot?.removeEventListener('scroll', cancelOnScroll);
|
||||
};
|
||||
|
||||
const cancelPending = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
cancelled = true;
|
||||
removeListeners();
|
||||
pendingScrollTargetRef.current = null;
|
||||
shouldPinAfterAlignRef.current = false;
|
||||
setPinnedStackedTarget(null);
|
||||
if (pendingScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(pendingScrollFrameRef.current);
|
||||
pendingScrollFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelOnUserInput = () => {
|
||||
cancelPending();
|
||||
};
|
||||
|
||||
const cancelOnScroll = () => {
|
||||
if (ignoreNextScrollEvents > 0) {
|
||||
ignoreNextScrollEvents -= 1;
|
||||
return;
|
||||
}
|
||||
cancelPending();
|
||||
};
|
||||
|
||||
const scrollRoot = diffScrollRef.current;
|
||||
window.addEventListener('wheel', cancelOnUserInput, { passive: true, capture: true });
|
||||
window.addEventListener('touchstart', cancelOnUserInput, { passive: true, capture: true });
|
||||
window.addEventListener('pointerdown', cancelOnUserInput, { capture: true });
|
||||
window.addEventListener('keydown', cancelOnUserInput, { capture: true });
|
||||
scrollRoot?.addEventListener('scroll', cancelOnScroll, { passive: true });
|
||||
|
||||
const tryAlign = () => {
|
||||
if (Date.now() > stopAt) {
|
||||
cancelPending();
|
||||
pendingScrollFrameRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
pendingScrollFrameRef.current = null;
|
||||
return;
|
||||
@@ -1173,10 +1145,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
ignoreNextScrollEvents += 1;
|
||||
const result = scrollToFile(currentTarget);
|
||||
if (!result.ok) {
|
||||
ignoreNextScrollEvents = Math.max(0, ignoreNextScrollEvents - 1);
|
||||
if (!result) {
|
||||
attempts += 1;
|
||||
if (attempts < maxAttempts) {
|
||||
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
|
||||
@@ -1187,25 +1157,6 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.aligned) {
|
||||
attempts += 1;
|
||||
if (!result.didMove) {
|
||||
stallFrames += 1;
|
||||
// If we're clamped (e.g. target is near bottom) give layout a few frames to settle
|
||||
// (diff expansion / highlight can change scrollHeight), but don't fight user input.
|
||||
if (stallFrames < 6 && (result.atScrollLimit || Math.abs(result.delta) > 1)) {
|
||||
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
stallFrames = 0;
|
||||
if (attempts < maxAttempts) {
|
||||
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pinSelectedFileHeaderToTopOnNavigate && shouldPinAfterAlignRef.current) {
|
||||
setPinnedStackedTarget(currentTarget);
|
||||
}
|
||||
@@ -1216,13 +1167,12 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
removeListeners();
|
||||
if (pendingScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(pendingScrollFrameRef.current);
|
||||
pendingScrollFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pinSelectedFileHeaderToTopOnNavigate, scrollToFile, selectedFile, stackedExpandRequestNonce]);
|
||||
}, [pinSelectedFileHeaderToTopOnNavigate, scrollToFile, selectedFile]);
|
||||
|
||||
const handleSelectFile = React.useCallback((value: string) => {
|
||||
setSelectedFile(value);
|
||||
@@ -1231,11 +1181,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleSelectFileAndScroll = React.useCallback((value: string) => {
|
||||
if (pendingScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(pendingScrollFrameRef.current);
|
||||
pendingScrollFrameRef.current = null;
|
||||
}
|
||||
pendingScrollTargetRef.current = null;
|
||||
cancelPendingScrollAlignment();
|
||||
|
||||
setSelectedFile(value);
|
||||
setSelectedFileStaged(false);
|
||||
@@ -1243,10 +1189,9 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
|
||||
shouldPinAfterAlignRef.current = true;
|
||||
pendingScrollTargetRef.current = value;
|
||||
setStackedExpandTarget(value);
|
||||
setStackedExpandRequestNonce((nonce) => nonce + 1);
|
||||
expandStackedFile(value);
|
||||
scrollToFile(value);
|
||||
}, [scrollToFile]);
|
||||
}, [cancelPendingScrollAlignment, expandStackedFile, scrollToFile]);
|
||||
|
||||
const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => {
|
||||
const nextLayout: 'inline' | 'side-by-side' =
|
||||
@@ -1346,7 +1291,6 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const renderStackedDiffView = () => {
|
||||
if (!effectiveDirectory) return null;
|
||||
|
||||
const defaultExpandedCount = getStackedViewDefaultExpandedCount(changedFiles.length);
|
||||
const getFileStaged = (path: string) => {
|
||||
if (forcedStaged !== null) {
|
||||
return forcedStaged;
|
||||
@@ -1372,27 +1316,26 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
<ScrollableOverlay
|
||||
ref={diffScrollRef}
|
||||
outerClassName="flex-1 min-h-0 h-full"
|
||||
className="[overflow-anchor:none]"
|
||||
disableHorizontal
|
||||
observeMutations={false}
|
||||
preventOverscroll
|
||||
data-diff-virtual-root
|
||||
data-diff-virtual-content
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{changedFiles.map((file, index) => (
|
||||
<div className="flex flex-col [overflow-anchor:none]" data-diff-virtual-content>
|
||||
{changedFiles.map((file) => (
|
||||
<MultiFileDiffEntry
|
||||
key={`${getFileStaged(file.path) ? 'staged' : 'unstaged'}:${file.path}`}
|
||||
directory={effectiveDirectory}
|
||||
file={file}
|
||||
layout={getLayoutForFile(file)}
|
||||
wrapLines={diffWrapLines}
|
||||
scrollRootRef={diffScrollRef}
|
||||
isSelected={file.path === selectedFile}
|
||||
isExpanded={expandedFiles.has(file.path)}
|
||||
isMounted={mountedStackedFiles.has(file.path) || file.path === selectedFile || file.path === pinnedStackedTarget}
|
||||
onSelect={handleSelectFile}
|
||||
onExpandedChange={handleStackedEntryExpandedChange}
|
||||
registerSectionRef={registerSectionRef}
|
||||
defaultCollapsed={stackedDefaultCollapsedAll ? true : index >= defaultExpandedCount}
|
||||
expandRequestPath={stackedExpandTarget}
|
||||
expandRequestNonce={stackedExpandRequestNonce}
|
||||
showOpenInEditorAction={showOpenInEditorAction}
|
||||
isOpeningInEditor={openingEditorFilePath === file.path}
|
||||
onOpenInEditor={(filePath, diffData) => {
|
||||
@@ -1470,6 +1413,23 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{changedFiles.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExpandOrCollapseAll}
|
||||
className="h-7 gap-1.5 px-2 text-muted-foreground hover:text-foreground"
|
||||
title={expandedFiles.size > 0 ? t('diffView.actions.collapseAll') : t('diffView.actions.expandAll')}
|
||||
>
|
||||
<Icon
|
||||
name={expandedFiles.size > 0 ? 'arrow-up-s' : 'arrow-down-s'}
|
||||
className="size-4"
|
||||
/>
|
||||
<span className="typography-ui-label hidden sm:inline">
|
||||
{expandedFiles.size > 0 ? t('diffView.actions.collapseAll') : t('diffView.actions.expandAll')}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{selectedFileEntry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
areFilesEqual,
|
||||
areOptionsEqual,
|
||||
FileDiff as PierreFileDiff,
|
||||
VirtualizedFileDiff,
|
||||
Virtualizer,
|
||||
type FileContents,
|
||||
type FileDiffMetadata,
|
||||
type FileDiffOptions,
|
||||
type DiffLineAnnotation,
|
||||
type SelectedLineRange,
|
||||
@@ -34,6 +37,7 @@ const LARGE_CONTENT_BYTES = 500_000;
|
||||
interface PierreDiffViewerProps {
|
||||
original: string;
|
||||
modified: string;
|
||||
fileDiff?: FileDiffMetadata;
|
||||
language: string;
|
||||
fileName?: string;
|
||||
renderSideBySide: boolean;
|
||||
@@ -109,10 +113,17 @@ function makeContentCacheKey(contents: string): string {
|
||||
return `${contents.length}:${fnv1a32(sample)}`;
|
||||
}
|
||||
|
||||
const extractSelectedCode = (original: string, modified: string, range: SelectedLineRange): string => {
|
||||
const extractSelectedCode = (
|
||||
original: string,
|
||||
modified: string,
|
||||
fileDiff: FileDiffMetadata | undefined,
|
||||
range: SelectedLineRange,
|
||||
): string => {
|
||||
// Default to modified if side is ambiguous, as users mostly comment on new code
|
||||
const isOriginal = range.side === 'deletions';
|
||||
const content = isOriginal ? original : modified;
|
||||
const content = fileDiff
|
||||
? (isOriginal ? fileDiff.deletionLines : fileDiff.additionLines).join('')
|
||||
: (isOriginal ? original : modified);
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Ensure bounds
|
||||
@@ -132,8 +143,111 @@ const isSameSelection = (left: SelectedLineRange | null, right: SelectedLineRang
|
||||
return left.start === right.start && left.end === right.end && left.side === right.side;
|
||||
};
|
||||
|
||||
const isScrollable = (value: string): boolean =>
|
||||
value === 'auto' || value === 'scroll' || value === 'overlay';
|
||||
|
||||
const findScrollParent = (node: HTMLElement | null): HTMLElement | null => {
|
||||
let current = node?.parentElement ?? null;
|
||||
while (current) {
|
||||
const style = window.getComputedStyle(current);
|
||||
if (isScrollable(style.overflowY)) return current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const preserveScrollPosition = (wrapper: HTMLElement | null, container: HTMLElement | null): (() => void) => {
|
||||
if (!wrapper || !container || typeof window === 'undefined') return () => {};
|
||||
|
||||
const scrollParent = findScrollParent(wrapper);
|
||||
if (!scrollParent) return () => {};
|
||||
|
||||
const height = container.getBoundingClientRect().height;
|
||||
if (!height) return () => {};
|
||||
|
||||
const top = wrapper.getBoundingClientRect().top - scrollParent.getBoundingClientRect().top;
|
||||
const previousMinHeight = container.style.minHeight;
|
||||
container.style.minHeight = `${Math.ceil(height)}px`;
|
||||
|
||||
let done = false;
|
||||
return () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
container.style.minHeight = previousMinHeight;
|
||||
|
||||
const nextTop = wrapper.getBoundingClientRect().top - scrollParent.getBoundingClientRect().top;
|
||||
const delta = nextTop - top;
|
||||
if (delta) {
|
||||
scrollParent.scrollTop += delta;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const waitForDiffReady = (
|
||||
container: HTMLElement,
|
||||
onReady: () => void,
|
||||
): (() => void) => {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
|
||||
let frameId: number | null = null;
|
||||
let observer: MutationObserver | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const finish = () => {
|
||||
if (cancelled) return;
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
if (!cancelled) onReady();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getRoot = (): ShadowRoot | undefined => {
|
||||
const host = container.querySelector('diffs-container');
|
||||
return host?.shadowRoot ?? undefined;
|
||||
};
|
||||
|
||||
const isReady = (root = getRoot()) => {
|
||||
return Boolean(root?.querySelector('[data-line]'));
|
||||
};
|
||||
|
||||
if (isReady()) {
|
||||
finish();
|
||||
} else if (typeof MutationObserver !== 'undefined') {
|
||||
observer = new MutationObserver(() => {
|
||||
const root = getRoot();
|
||||
if (!root) return;
|
||||
if (isReady(root)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
observer?.disconnect();
|
||||
observer = new MutationObserver(() => {
|
||||
if (isReady(root)) {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
});
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
} else {
|
||||
frameId = window.requestAnimationFrame(finish);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
observer?.disconnect();
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
type SharedVirtualizer = {
|
||||
virtualizer: Virtualizer;
|
||||
root: Document | HTMLElement;
|
||||
release: () => void;
|
||||
};
|
||||
|
||||
@@ -192,6 +306,7 @@ function acquireSharedVirtualizer(container: HTMLElement): SharedVirtualizer | n
|
||||
|
||||
return {
|
||||
virtualizer: entry.virtualizer,
|
||||
root: target.root,
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
@@ -208,9 +323,48 @@ function acquireSharedVirtualizer(container: HTMLElement): SharedVirtualizer | n
|
||||
};
|
||||
}
|
||||
|
||||
const wakeVirtualizer = (
|
||||
instance: PierreFileDiff<unknown>,
|
||||
sharedVirtualizer: SharedVirtualizer | null,
|
||||
forceUpdate: () => void,
|
||||
): (() => void) => {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
|
||||
const frameIds: number[] = [];
|
||||
const run = () => {
|
||||
try {
|
||||
instance.rerender();
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
|
||||
const root = sharedVirtualizer?.root;
|
||||
if (root instanceof HTMLElement) {
|
||||
root.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
} else {
|
||||
document.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
frameIds.push(window.requestAnimationFrame(run));
|
||||
frameIds.push(window.requestAnimationFrame(() => {
|
||||
frameIds.push(window.requestAnimationFrame(run));
|
||||
}));
|
||||
|
||||
return () => {
|
||||
for (const frameId of frameIds) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
original,
|
||||
modified,
|
||||
fileDiff,
|
||||
language,
|
||||
fileName,
|
||||
renderSideBySide,
|
||||
@@ -229,7 +383,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
source: 'diff',
|
||||
fileLabel: fileName || 'unknown',
|
||||
language,
|
||||
getCodeForRange: (range) => extractSelectedCode(original, modified, range),
|
||||
getCodeForRange: (range) => extractSelectedCode(original, modified, fileDiff, range),
|
||||
toStoreRange: (range) => ({
|
||||
startLine: range.start,
|
||||
endLine: range.end,
|
||||
@@ -379,12 +533,28 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
|
||||
const diffThemeKey = `${lightTheme.metadata.id}:${darkTheme.metadata.id}:${isDark ? 'dark' : 'light'}`;
|
||||
|
||||
const isLargeContent = useMemo(() => {
|
||||
if (fileDiff) {
|
||||
const deletionLength = fileDiff.deletionLines.reduce((total, line) => total + line.length, 0);
|
||||
const additionLength = fileDiff.additionLines.reduce((total, line) => total + line.length, 0);
|
||||
return Math.max(deletionLength, additionLength) > LARGE_CONTENT_BYTES;
|
||||
}
|
||||
|
||||
return Math.max(original.length, modified.length) > LARGE_CONTENT_BYTES;
|
||||
}, [fileDiff, modified.length, original.length]);
|
||||
|
||||
const diffRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const diffContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const diffInstanceRef = useRef<PierreFileDiff<unknown> | null>(null);
|
||||
const sharedVirtualizerRef = useRef<SharedVirtualizer | null>(null);
|
||||
const instanceVirtualizerRef = useRef<Virtualizer | null>(null);
|
||||
const instanceWorkerPoolRef = useRef<unknown>(null);
|
||||
const instanceVirtualHunkSeparatorsRef = useRef<FileDiffOptions<unknown>['hunkSeparators'] | undefined>(undefined);
|
||||
const instanceFileDiffRef = useRef<FileDiffMetadata | undefined>(undefined);
|
||||
const instanceOldFileRef = useRef<FileContents | undefined>(undefined);
|
||||
const instanceNewFileRef = useRef<FileContents | undefined>(undefined);
|
||||
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
||||
const workerPool = useWorkerPool(renderSideBySide ? 'split' : 'unified');
|
||||
const workerPool = useWorkerPool(isLargeContent ? 'unified' : (renderSideBySide ? 'split' : 'unified'));
|
||||
|
||||
const lightResolvedTheme = useMemo(() => getResolvedShikiTheme(lightTheme), [lightTheme]);
|
||||
const darkResolvedTheme = useMemo(() => getResolvedShikiTheme(darkTheme), [darkTheme]);
|
||||
@@ -464,11 +634,6 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
}, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]);
|
||||
|
||||
|
||||
const isLargeContent = useMemo(() =>
|
||||
Math.max(original.length, modified.length) > LARGE_CONTENT_BYTES,
|
||||
[original.length, modified.length],
|
||||
);
|
||||
|
||||
const options = useMemo(() => ({
|
||||
theme: {
|
||||
dark: darkTheme.metadata.id,
|
||||
@@ -483,6 +648,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
// Perf: degrade tokenization/highlighting for large files (>500KB)
|
||||
maxLineDiffLength: isLargeContent ? 0 : 1000,
|
||||
maxLineLengthForHighlighting: isLargeContent ? 1 : 1000,
|
||||
tokenizeMaxLineLength: isLargeContent ? 1 : 1000,
|
||||
expansionLineCount: 20,
|
||||
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
|
||||
disableFileHeader: true,
|
||||
@@ -508,70 +674,136 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
lineAnnotationsRef.current = lineAnnotations;
|
||||
}, [lineAnnotations]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = diffContainerRef.current;
|
||||
return () => {
|
||||
diffInstanceRef.current?.cleanUp();
|
||||
diffInstanceRef.current = null;
|
||||
sharedVirtualizerRef.current?.release();
|
||||
sharedVirtualizerRef.current = null;
|
||||
instanceVirtualizerRef.current = null;
|
||||
instanceWorkerPoolRef.current = null;
|
||||
instanceVirtualHunkSeparatorsRef.current = undefined;
|
||||
instanceFileDiffRef.current = undefined;
|
||||
instanceOldFileRef.current = undefined;
|
||||
instanceNewFileRef.current = undefined;
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const container = diffContainerRef.current;
|
||||
const wrapper = diffRootRef.current;
|
||||
if (!container) return;
|
||||
if (!workerPool) return;
|
||||
|
||||
// Dispose previous instance
|
||||
diffInstanceRef.current?.cleanUp();
|
||||
diffInstanceRef.current = null;
|
||||
sharedVirtualizerRef.current?.release();
|
||||
sharedVirtualizerRef.current = null;
|
||||
container.innerHTML = '';
|
||||
|
||||
const sharedVirtualizer = acquireSharedVirtualizer(container);
|
||||
const preserveDone = preserveScrollPosition(wrapper, container);
|
||||
let sharedVirtualizer = sharedVirtualizerRef.current;
|
||||
if (!sharedVirtualizer) {
|
||||
sharedVirtualizer = acquireSharedVirtualizer(container);
|
||||
sharedVirtualizerRef.current = sharedVirtualizer;
|
||||
}
|
||||
sharedVirtualizerRef.current = sharedVirtualizer;
|
||||
const virtualizer = sharedVirtualizer?.virtualizer ?? null;
|
||||
|
||||
const instance = sharedVirtualizer
|
||||
? new VirtualizedFileDiff(
|
||||
options as unknown as FileDiffOptions<unknown>,
|
||||
sharedVirtualizer.virtualizer,
|
||||
VIRTUAL_METRICS,
|
||||
workerPool,
|
||||
)
|
||||
: new PierreFileDiff(options as unknown as FileDiffOptions<unknown>, workerPool);
|
||||
diffInstanceRef.current = instance;
|
||||
lastAppliedSelectionRef.current = null;
|
||||
|
||||
const oldFile: FileContents = {
|
||||
const oldFile: FileContents | undefined = fileDiff ? undefined : {
|
||||
name: fileName || '',
|
||||
contents: original,
|
||||
lang: language as FileContents['lang'],
|
||||
cacheKey: `old:${diffThemeKey}:${fileName}:${makeContentCacheKey(original)}`,
|
||||
};
|
||||
const newFile: FileContents = {
|
||||
const newFile: FileContents | undefined = fileDiff ? undefined : {
|
||||
name: fileName || '',
|
||||
contents: modified,
|
||||
lang: language as FileContents['lang'],
|
||||
cacheKey: `new:${diffThemeKey}:${fileName}:${makeContentCacheKey(modified)}`,
|
||||
};
|
||||
|
||||
instance.render({
|
||||
oldFile,
|
||||
newFile,
|
||||
lineAnnotations: lineAnnotationsRef.current,
|
||||
containerWrapper: container,
|
||||
});
|
||||
const targetChanged = fileDiff
|
||||
? instanceFileDiffRef.current !== fileDiff
|
||||
: instanceFileDiffRef.current !== undefined
|
||||
|| !oldFile
|
||||
|| !newFile
|
||||
|| !instanceOldFileRef.current
|
||||
|| !instanceNewFileRef.current
|
||||
|| !areFilesEqual(instanceOldFileRef.current, oldFile)
|
||||
|| !areFilesEqual(instanceNewFileRef.current, newFile);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
forceUpdate();
|
||||
const currentInstance = diffInstanceRef.current;
|
||||
const shouldReset = Boolean(
|
||||
currentInstance
|
||||
&& (
|
||||
instanceVirtualizerRef.current !== virtualizer
|
||||
|| instanceWorkerPoolRef.current !== workerPool
|
||||
|| (virtualizer && (instanceVirtualHunkSeparatorsRef.current !== options.hunkSeparators || targetChanged))
|
||||
)
|
||||
);
|
||||
|
||||
if (shouldReset) {
|
||||
currentInstance?.cleanUp();
|
||||
diffInstanceRef.current = null;
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
let instance = diffInstanceRef.current;
|
||||
const forceRender = !shouldReset && currentInstance
|
||||
? !areOptionsEqual(currentInstance.options, options)
|
||||
: false;
|
||||
if (!instance) {
|
||||
instance = sharedVirtualizer
|
||||
? new VirtualizedFileDiff(
|
||||
options as unknown as FileDiffOptions<unknown>,
|
||||
sharedVirtualizer.virtualizer,
|
||||
VIRTUAL_METRICS,
|
||||
workerPool,
|
||||
)
|
||||
: new PierreFileDiff(options as unknown as FileDiffOptions<unknown>, workerPool);
|
||||
diffInstanceRef.current = instance;
|
||||
lastAppliedSelectionRef.current = null;
|
||||
} else {
|
||||
instance.setOptions(options as unknown as FileDiffOptions<unknown>);
|
||||
}
|
||||
|
||||
instanceVirtualizerRef.current = virtualizer;
|
||||
instanceWorkerPoolRef.current = workerPool;
|
||||
instanceVirtualHunkSeparatorsRef.current = virtualizer ? options.hunkSeparators : undefined;
|
||||
instanceFileDiffRef.current = fileDiff;
|
||||
instanceOldFileRef.current = oldFile;
|
||||
instanceNewFileRef.current = newFile;
|
||||
|
||||
if (fileDiff) {
|
||||
instance.render({
|
||||
fileDiff,
|
||||
forceRender,
|
||||
lineAnnotations: lineAnnotationsRef.current,
|
||||
containerWrapper: container,
|
||||
});
|
||||
} else {
|
||||
if (!oldFile || !newFile) return;
|
||||
|
||||
instance.render({
|
||||
oldFile,
|
||||
newFile,
|
||||
forceRender,
|
||||
lineAnnotations: lineAnnotationsRef.current,
|
||||
containerWrapper: container,
|
||||
});
|
||||
}
|
||||
|
||||
const cancelReady = waitForDiffReady(container, () => {
|
||||
preserveDone();
|
||||
wakeVirtualizer(instance, sharedVirtualizer, forceUpdate);
|
||||
});
|
||||
|
||||
return () => {
|
||||
instance.cleanUp();
|
||||
if (diffInstanceRef.current === instance) {
|
||||
diffInstanceRef.current = null;
|
||||
}
|
||||
sharedVirtualizer?.release();
|
||||
if (sharedVirtualizer && sharedVirtualizerRef.current === sharedVirtualizer) {
|
||||
sharedVirtualizerRef.current = null;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
cancelReady();
|
||||
preserveDone();
|
||||
};
|
||||
}, [diffThemeKey, fileName, language, modified, options, original, workerPool]);
|
||||
}, [diffThemeKey, fileDiff, fileName, language, modified, options, original, workerPool]);
|
||||
|
||||
useEffect(() => {
|
||||
const instance = diffInstanceRef.current;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from '@pierre/diffs';
|
||||
|
||||
const PATCH_DIFF_CACHE_LIMIT = 64;
|
||||
const patchFileDiffCache = new Map<string, FileDiffMetadata>();
|
||||
|
||||
export const fileDiffFromContent = (file: string, before: string, after: string): FileDiffMetadata => {
|
||||
if (!before && !after) {
|
||||
return emptyFileDiff(file);
|
||||
}
|
||||
|
||||
return parseDiffFromFile(
|
||||
{ name: file, contents: before },
|
||||
{ name: file, contents: after },
|
||||
);
|
||||
};
|
||||
|
||||
export const fileDiffFromPatch = (file: string, patch: string): FileDiffMetadata => {
|
||||
const key = `${file}\0${patch}`;
|
||||
const cached = patchFileDiffCache.get(key);
|
||||
if (cached) {
|
||||
patchFileDiffCache.delete(key);
|
||||
patchFileDiffCache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const completeContents = completePatchContents(patch);
|
||||
const value = completeContents
|
||||
? fileDiffFromContent(file, completeContents.before, completeContents.after)
|
||||
: (parsePatchFiles(withPatchHeader(file, patch))[0]?.files[0] ?? emptyFileDiff(file));
|
||||
patchFileDiffCache.set(key, value);
|
||||
|
||||
while (patchFileDiffCache.size > PATCH_DIFF_CACHE_LIMIT) {
|
||||
const firstKey = patchFileDiffCache.keys().next().value;
|
||||
if (!firstKey) break;
|
||||
patchFileDiffCache.delete(firstKey);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const withPatchHeader = (file: string, patch: string): string => {
|
||||
if (!patch.trim()) {
|
||||
return patch;
|
||||
}
|
||||
|
||||
if (patch.startsWith('diff --git ') || /^--- [^\n]*\r?\n\+\+\+ /m.test(patch)) {
|
||||
return patch;
|
||||
}
|
||||
|
||||
return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n${patch}`;
|
||||
};
|
||||
|
||||
const completePatchContents = (patch: string): { before: string; after: string } | undefined => {
|
||||
if (!patch.startsWith('diff --git ') && !/^--- [^\n]*\t?\r?\n\+\+\+ [^\n]*\t?(?:\r?\n|$)/m.test(patch)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hunkMatches = [...patch.matchAll(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@[^\n]*(?:\r?\n|$)/gm)];
|
||||
if (hunkMatches.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hunk = hunkMatches[0];
|
||||
const oldStart = Number.parseInt(hunk[1] ?? '', 10);
|
||||
const newStart = Number.parseInt(hunk[2] ?? '', 10);
|
||||
if (oldStart > 1 || newStart > 1 || !Number.isFinite(oldStart) || !Number.isFinite(newStart)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hunkStart = (hunk.index ?? 0) + hunk[0].length;
|
||||
const body = patch.slice(hunkStart);
|
||||
const before: Array<{ text: string; newline: boolean }> = [];
|
||||
const after: Array<{ text: string; newline: boolean }> = [];
|
||||
let previous: '-' | '+' | ' ' | undefined;
|
||||
|
||||
for (const rawLine of body.split(/\r?\n/)) {
|
||||
if (rawLine.startsWith('diff --git ') || rawLine.startsWith('@@ ')) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (rawLine.startsWith('\\')) {
|
||||
if (previous === '-' || previous === ' ') {
|
||||
const value = before.at(-1);
|
||||
if (value) value.newline = false;
|
||||
}
|
||||
if (previous === '+' || previous === ' ') {
|
||||
const value = after.at(-1);
|
||||
if (value) value.newline = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rawLine.startsWith('-')) {
|
||||
before.push({ text: rawLine.slice(1), newline: true });
|
||||
previous = '-';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rawLine.startsWith('+')) {
|
||||
after.push({ text: rawLine.slice(1), newline: true });
|
||||
previous = '+';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rawLine.startsWith(' ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
before.push({ text: rawLine.slice(1), newline: true });
|
||||
after.push({ text: rawLine.slice(1), newline: true });
|
||||
previous = ' ';
|
||||
}
|
||||
|
||||
return {
|
||||
before: joinPatchLines(before),
|
||||
after: joinPatchLines(after),
|
||||
};
|
||||
};
|
||||
|
||||
const joinPatchLines = (lines: Array<{ text: string; newline: boolean }>): string =>
|
||||
lines.map((line) => line.text + (line.newline ? '\n' : '')).join('');
|
||||
|
||||
const emptyFileDiff = (file: string): FileDiffMetadata =>
|
||||
parseDiffFromFile({ name: file, contents: '' }, { name: file, contents: '' });
|
||||
@@ -1198,9 +1198,14 @@ export const dict = {
|
||||
'diffView.state.failedToLoadDiff': 'Failed to load diff',
|
||||
'diffView.state.loadingDiff': 'Loading diff...',
|
||||
'diffView.state.loadingChanges': 'Loading changes...',
|
||||
'diffView.state.largeDiff': 'Large diff ({count} changed lines)',
|
||||
'diffView.state.largeDiffDescription': 'Rendering may be slow. You can still view the diff by clicking below.',
|
||||
'diffView.summary.changedFilesSingle': '{count} file changed',
|
||||
'diffView.summary.changedFilesPlural': '{count} files changed',
|
||||
'diffView.actions.retry': 'Retry',
|
||||
'diffView.actions.renderAnyway': 'Render anyway',
|
||||
'diffView.actions.expandAll': 'Expand all',
|
||||
'diffView.actions.collapseAll': 'Collapse all',
|
||||
'diffView.actions.disableLineWrap': 'Disable line wrap',
|
||||
'diffView.actions.enableLineWrap': 'Enable line wrap',
|
||||
'diffView.actions.openFileInEditorAtChange': 'Open this file in editor at change',
|
||||
|
||||
@@ -1164,9 +1164,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.state.failedToLoadDiff": "No se pudo cargar la diferencia",
|
||||
"diffView.state.loadingDiff": "Cargando diff...",
|
||||
"diffView.state.loadingChanges": "Cargando cambios...",
|
||||
"diffView.state.largeDiff": "Diff grande ({count} líneas modificadas)",
|
||||
"diffView.state.largeDiffDescription": "El renderizado puede ser lento. Aun así puedes ver el diff con el botón de abajo.",
|
||||
"diffView.summary.changedFilesSingle": "{count} archivo modificado",
|
||||
"diffView.summary.changedFilesPlural": "{count} archivos modificados",
|
||||
"diffView.actions.retry": "Volver a intentar",
|
||||
"diffView.actions.renderAnyway": "Renderizar de todos modos",
|
||||
"diffView.actions.expandAll": "Expandir todo",
|
||||
"diffView.actions.collapseAll": "Contraer todo",
|
||||
"diffView.actions.disableLineWrap": "Desactivar ajuste de línea",
|
||||
"diffView.actions.enableLineWrap": "Activar ajuste de línea",
|
||||
"diffView.actions.openFileInEditorAtChange": "Abrir este archivo en el editor en el cambio",
|
||||
|
||||
@@ -1071,9 +1071,14 @@ export const dict = {
|
||||
'diffView.state.failedToLoadDiff': 'Échec du chargement du différentiel',
|
||||
'diffView.state.loadingDiff': 'Chargement du différentiel...',
|
||||
'diffView.state.loadingChanges': 'Chargement des modifications...',
|
||||
'diffView.state.largeDiff': 'Diff volumineux ({count} lignes modifiées)',
|
||||
'diffView.state.largeDiffDescription': 'Le rendu peut être lent. Vous pouvez tout de même afficher le diff avec le bouton ci-dessous.',
|
||||
'diffView.summary.changedFilesSingle': 'Le fichier {count} a été modifié',
|
||||
'diffView.summary.changedFilesPlural': 'Fichiers {count} modifiés',
|
||||
'diffView.actions.retry': 'Réessayer',
|
||||
'diffView.actions.renderAnyway': 'Afficher quand même',
|
||||
'diffView.actions.expandAll': 'Tout développer',
|
||||
'diffView.actions.collapseAll': 'Tout réduire',
|
||||
'diffView.actions.disableLineWrap': 'Désactiver le retour à la ligne',
|
||||
'diffView.actions.enableLineWrap': 'Activer le retour à la ligne',
|
||||
'diffView.actions.openFileInEditorAtChange': 'Ouvrez ce fichier dans l\'éditeur lors du changement',
|
||||
|
||||
@@ -1201,9 +1201,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.state.failedToLoadDiff': '변경사항을 불러오지 못했습니다',
|
||||
'diffView.state.loadingDiff': '변경사항 불러오는 중…',
|
||||
'diffView.state.loadingChanges': '변경 사항 로드 중…',
|
||||
'diffView.state.largeDiff': '큰 diff({count}개 변경 줄)',
|
||||
'diffView.state.largeDiffDescription': '렌더링이 느릴 수 있습니다. 아래 버튼으로 diff를 계속 볼 수 있습니다.',
|
||||
'diffView.summary.changedFilesSingle': '파일 {count}개 변경됨',
|
||||
'diffView.summary.changedFilesPlural': '파일 {count}개 변경됨',
|
||||
'diffView.actions.retry': '다시 시도',
|
||||
'diffView.actions.renderAnyway': '그래도 렌더링',
|
||||
'diffView.actions.expandAll': '모두 펼치기',
|
||||
'diffView.actions.collapseAll': '모두 접기',
|
||||
'diffView.actions.disableLineWrap': '줄 바꿈 끄기',
|
||||
'diffView.actions.enableLineWrap': '줄 바꿈 켜기',
|
||||
'diffView.actions.openFileInEditorAtChange': '변경 위치에서 이 파일을 에디터로 열기',
|
||||
|
||||
@@ -1410,8 +1410,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'desktopHostSwitcher.toast.sshFailedToConnect': 'Nie udało się połączyć z instancją SSH „{host}”',
|
||||
'diffView.actions.disableLineWrap': 'Wyłącz zawijanie linii',
|
||||
'diffView.actions.enableLineWrap': 'Włącz zawijanie linii',
|
||||
'diffView.actions.expandAll': 'Rozwiń wszystko',
|
||||
'diffView.actions.collapseAll': 'Zwiń wszystko',
|
||||
'diffView.actions.openFileAtFirstChangedLine': 'Otwórz plik na pierwszej zmienionej linii',
|
||||
'diffView.actions.openFileInEditorAtChange': 'Otwórz plik w edytorze na zmianie',
|
||||
'diffView.actions.renderAnyway': 'Renderuj mimo to',
|
||||
'diffView.actions.retry': 'Ponów',
|
||||
'diffView.binary.unavailable': 'Nie można wyświetlić zawartości tego pliku.',
|
||||
'diffView.change.copied': 'Skopiowany plik',
|
||||
@@ -1429,6 +1432,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.selector.selectFile': 'Wybierz plik',
|
||||
'diffView.state.cleanWorkingTree': 'Drzewo robocze jest czyste, brak zmian do wyświetlenia',
|
||||
'diffView.state.failedToLoadDiff': 'Nie udało się wczytać diffu',
|
||||
'diffView.state.largeDiff': 'Duży diff ({count} zmienionych linii)',
|
||||
'diffView.state.largeDiffDescription': 'Renderowanie może być wolne. Nadal możesz wyświetlić diff przyciskiem poniżej.',
|
||||
'diffView.state.loadingChanges': 'Ładowanie zmian...',
|
||||
'diffView.state.loadingDiff': 'Ładowanie diffu...',
|
||||
'diffView.state.loadingRepositoryStatus': 'Ładowanie stanu repozytorium...',
|
||||
|
||||
@@ -1164,9 +1164,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.state.failedToLoadDiff": "Não foi possível carregar a diferencia",
|
||||
"diffView.state.loadingDiff": "Carregando diff...",
|
||||
"diffView.state.loadingChanges": "Carregando alterações...",
|
||||
"diffView.state.largeDiff": "Diff grande ({count} linhas alteradas)",
|
||||
"diffView.state.largeDiffDescription": "A renderização pode ser lenta. Você ainda pode ver o diff pelo botão abaixo.",
|
||||
"diffView.summary.changedFilesSingle": "{count} arquivo modificado",
|
||||
"diffView.summary.changedFilesPlural": "{count} arquivos modificados",
|
||||
"diffView.actions.retry": "Tentar novamente",
|
||||
"diffView.actions.renderAnyway": "Renderizar mesmo assim",
|
||||
"diffView.actions.expandAll": "Expandir tudo",
|
||||
"diffView.actions.collapseAll": "Recolher tudo",
|
||||
"diffView.actions.disableLineWrap": "Desativar ajuste de linha",
|
||||
"diffView.actions.enableLineWrap": "Ativar ajuste de linha",
|
||||
"diffView.actions.openFileInEditorAtChange": "Abrir este arquivo no editor nesta alteração",
|
||||
|
||||
@@ -1164,9 +1164,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
"diffView.state.failedToLoadDiff": "Не вдалося завантажити diff",
|
||||
"diffView.state.loadingDiff": "Завантаження diff...",
|
||||
"diffView.state.loadingChanges": "Завантаження змін...",
|
||||
"diffView.state.largeDiff": "Великий diff ({count} змінених рядків)",
|
||||
"diffView.state.largeDiffDescription": "Рендеринг може бути повільним. Ви все одно можете переглянути diff кнопкою нижче.",
|
||||
"diffView.summary.changedFilesSingle": "Змінено файл: {count}",
|
||||
"diffView.summary.changedFilesPlural": "Змінено файлів: {count}",
|
||||
"diffView.actions.retry": "Повторити спробу",
|
||||
"diffView.actions.renderAnyway": "Все одно відрендерити",
|
||||
"diffView.actions.expandAll": "Розгорнути все",
|
||||
"diffView.actions.collapseAll": "Згорнути все",
|
||||
"diffView.actions.disableLineWrap": "Вимкнути перенос рядків",
|
||||
"diffView.actions.enableLineWrap": "Увімкнути перенос рядків",
|
||||
"diffView.actions.openFileInEditorAtChange": "Відкрити цей файл у редакторі на зміні",
|
||||
|
||||
@@ -1164,9 +1164,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.state.failedToLoadDiff': '加载差异失败',
|
||||
'diffView.state.loadingDiff': '正在加载差异...',
|
||||
'diffView.state.loadingChanges': '正在加载变更...',
|
||||
'diffView.state.largeDiff': '大型差异({count} 行变更)',
|
||||
'diffView.state.largeDiffDescription': '渲染可能较慢。你仍可点击下方按钮查看差异。',
|
||||
'diffView.summary.changedFilesSingle': '{count} 个文件已变更',
|
||||
'diffView.summary.changedFilesPlural': '{count} 个文件已变更',
|
||||
'diffView.actions.retry': '重试',
|
||||
'diffView.actions.renderAnyway': '仍然渲染',
|
||||
'diffView.actions.expandAll': '全部展开',
|
||||
'diffView.actions.collapseAll': '全部折叠',
|
||||
'diffView.actions.disableLineWrap': '关闭自动换行',
|
||||
'diffView.actions.enableLineWrap': '开启自动换行',
|
||||
'diffView.actions.openFileInEditorAtChange': '在编辑器中打开此文件并定位变更',
|
||||
|
||||
@@ -1174,9 +1174,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.state.failedToLoadDiff': '載入差異失敗',
|
||||
'diffView.state.loadingDiff': '正在載入差異...',
|
||||
'diffView.state.loadingChanges': '正在載入變更...',
|
||||
'diffView.state.largeDiff': '大型差異({count} 行變更)',
|
||||
'diffView.state.largeDiffDescription': '渲染可能較慢。你仍可點擊下方按鈕查看差異。',
|
||||
'diffView.summary.changedFilesSingle': '{count} 個檔案已變更',
|
||||
'diffView.summary.changedFilesPlural': '{count} 個檔案已變更',
|
||||
'diffView.actions.retry': '重試',
|
||||
'diffView.actions.renderAnyway': '仍然渲染',
|
||||
'diffView.actions.expandAll': '全部展開',
|
||||
'diffView.actions.collapseAll': '全部折疊',
|
||||
'diffView.actions.disableLineWrap': '關閉自動換行',
|
||||
'diffView.actions.enableLineWrap': '開啟自動換行',
|
||||
'diffView.actions.openFileInEditorAtChange': '在編輯器中開啟此檔案並定位變更',
|
||||
|
||||
Reference in New Issue
Block a user