import React from 'react';
import { RiArrowDownSLine, RiArrowRightSLine, RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react';
import { useUIStore } from '@/stores/useUIStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useGitStore, useGitStatus, useIsGitRepo, useGitFileCount } from '@/stores/useGitStore';
import { cn } from '@/lib/utils';
import type { GitStatus } from '@/lib/api/types';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
import type { DiffViewMode } from '@/components/chat/message/types';
import { PierreDiffViewer } from './PierreDiffViewer';
import { useDeviceInfo } from '@/lib/device';
// Minimum width for side-by-side diff view (px)
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
const DIFF_REQUEST_TIMEOUT_MS = 15000;
// Perf: limit concurrent expanded diffs in stacked view.
// Expanding many diffs mounts many Pierre instances + lots of DOM.
const getStackedViewDefaultExpandedCount = (fileCount: number): number => {
if (fileCount <= 6) return fileCount;
if (fileCount <= 12) return 6;
if (fileCount <= 25) return 4;
return 2;
};
type FileEntry = GitStatus['files'][number] & {
insertions: number;
deletions: number;
isNew: boolean;
};
type DiffData = { original: string; modified: string; isBinary?: boolean };
const BinaryDiffPlaceholder = React.memo(() => {
return (
Content of this file cannot be viewed.
);
});
type DiffTabViewMode = 'single' | 'stacked';
type ChangeDescriptor = {
code: string;
color: string;
description: string;
};
const CHANGE_DESCRIPTORS: Record = {
'?': { code: '?', color: 'var(--status-info)', description: 'Untracked file' },
A: { code: 'A', color: 'var(--status-success)', description: 'New file' },
D: { code: 'D', color: 'var(--status-error)', description: 'Deleted file' },
R: { code: 'R', color: 'var(--status-info)', description: 'Renamed file' },
C: { code: 'C', color: 'var(--status-info)', description: 'Copied file' },
M: { code: 'M', color: 'var(--status-warning)', description: 'Modified file' },
};
const DEFAULT_CHANGE_DESCRIPTOR = CHANGE_DESCRIPTORS.M;
const DIFF_VIEW_MODE_OPTIONS: Array<{
value: DiffTabViewMode;
label: string;
description: string;
}> = [
{
value: 'single',
label: 'Single file',
description: 'Show one file at a time',
},
{
value: 'stacked',
label: 'All files',
description: 'Stack all modified files together',
},
];
const getChangeSymbol = (file: GitStatus['files'][number]): string => {
const indexCode = file.index?.trim();
const workingCode = file.working_dir?.trim();
if (indexCode && indexCode !== '?') return indexCode.charAt(0);
if (workingCode) return workingCode.charAt(0);
return indexCode?.charAt(0) || workingCode?.charAt(0) || 'M';
};
const describeChange = (file: GitStatus['files'][number]): ChangeDescriptor => {
const symbol = getChangeSymbol(file);
return CHANGE_DESCRIPTORS[symbol] ?? DEFAULT_CHANGE_DESCRIPTOR;
};
const isNewStatusFile = (file: GitStatus['files'][number]): boolean => {
const { index, working_dir: workingDir } = file;
return index === 'A' || workingDir === 'A' || index === '?' || workingDir === '?';
};
const formatDiffTotals = (insertions?: number, deletions?: number) => {
const added = insertions ?? 0;
const removed = deletions ?? 0;
if (!added && !removed) return null;
return (
{added ? +{added} : null}
{removed ? -{removed} : null}
);
};
interface FileSelectorProps {
changedFiles: FileEntry[];
selectedFile: string | null;
selectedFileEntry: FileEntry | null;
onSelectFile: (path: string) => void;
isMobile: boolean;
showModeSelector?: boolean;
mode?: DiffTabViewMode;
onModeChange?: (mode: DiffTabViewMode) => void;
}
const FileSelector = React.memo(({
changedFiles,
selectedFile,
selectedFileEntry,
onSelectFile,
isMobile,
showModeSelector = false,
mode,
onModeChange,
}) => {
const getLabel = React.useCallback((path: string) => {
if (!isMobile) return path;
const lastSlash = path.lastIndexOf('/');
return lastSlash >= 0 ? path.slice(lastSlash + 1) : path;
}, [isMobile]);
if (changedFiles.length === 0) return null;
return (
{showModeSelector && mode && onModeChange ? (
<>
View mode
onModeChange(value as DiffTabViewMode)}
>
{DIFF_VIEW_MODE_OPTIONS.map((option) => (
{option.label}
))}
>
) : null}
{changedFiles.map((file) => (
{getLabel(file.path)}
{formatDiffTotals(file.insertions, file.deletions)}
))}
);
});
interface DiffViewModeSelectorProps {
mode: DiffTabViewMode;
onModeChange: (mode: DiffTabViewMode) => void;
}
const DiffViewModeSelector = React.memo(({ mode, onModeChange }) => {
const currentOption =
DIFF_VIEW_MODE_OPTIONS.find((option) => option.value === mode) ?? DIFF_VIEW_MODE_OPTIONS[0];
return (
onModeChange(value as DiffTabViewMode)}
>
{DIFF_VIEW_MODE_OPTIONS.map((option) => (
{option.label}
{option.description}
))}
);
});
interface FileListProps {
changedFiles: FileEntry[];
selectedFile: string | null;
onSelectFile: (path: string) => void;
}
const FileList = React.memo(({
changedFiles,
selectedFile,
onSelectFile,
}) => {
if (changedFiles.length === 0) return null;
return (
{changedFiles.map((file) => {
const descriptor = describeChange(file);
const isActive = selectedFile === file.path;
return (
-
);
})}
);
});
// Image diff viewer for binary image files
interface ImageDiffViewerProps {
filePath: string;
diff: DiffData;
isVisible: boolean;
renderSideBySide: boolean;
}
const ImageDiffViewer = React.memo(({
filePath,
diff,
isVisible,
renderSideBySide,
}) => {
const hasOriginal = diff.original.length > 0;
const hasModified = diff.modified.length > 0;
if (!isVisible) {
return ;
}
// Render side-by-side or stacked based on preference
const containerClass = renderSideBySide
? 'flex flex-row gap-6 items-start justify-center h-full'
: 'flex flex-col gap-4 items-center';
const imageContainerClass = renderSideBySide
? 'flex flex-col items-center gap-2 flex-1 min-w-0 h-full'
: 'flex flex-col items-center gap-2';
return (
{hasOriginal && (
Original
)}
{hasModified && (
{hasOriginal ? 'Modified' : 'New'}
)}
);
});
interface InlineImageDiffViewerProps {
filePath: string;
diff: DiffData;
renderSideBySide: boolean;
}
const InlineImageDiffViewer = React.memo(({
filePath,
diff,
renderSideBySide,
}) => {
const hasOriginal = diff.original.length > 0;
const hasModified = diff.modified.length > 0;
const containerClass = renderSideBySide
? 'flex flex-row gap-6 items-start justify-center'
: 'flex flex-col gap-4 items-center';
const imageContainerClass = renderSideBySide
? 'flex flex-col items-center gap-2 flex-1 min-w-0'
: 'flex flex-col items-center gap-2';
return (
{hasOriginal && (
Original
)}
{hasModified && (
{hasOriginal ? 'Modified' : 'New'}
)}
);
});
interface InlineDiffViewerProps {
filePath: string;
diff: DiffData;
renderSideBySide: boolean;
wrapLines: boolean;
}
const InlineDiffViewer = React.memo(({
filePath,
diff,
renderSideBySide,
wrapLines,
}) => {
const language = React.useMemo(
() => getLanguageFromExtension(filePath) || 'text',
[filePath]
);
if (diff.isBinary) {
return ;
}
if (isImageFile(filePath)) {
return (
);
}
return (
);
});
// Single diff viewer instance
interface SingleDiffViewerProps {
filePath: string;
diff: DiffData;
isVisible: boolean;
renderSideBySide: boolean;
wrapLines: boolean;
}
const SingleDiffViewer = React.memo(({
filePath,
diff,
isVisible,
renderSideBySide,
wrapLines,
}) => {
const language = React.useMemo(
() => getLanguageFromExtension(filePath) || 'text',
[filePath]
);
if (diff.isBinary) {
return ;
}
// Don't render if not visible (memory optimization)
if (!isVisible) {
return null;
}
// Check if this is an image file
if (isImageFile(filePath)) {
return (
);
}
return (
);
});
interface MultiFileDiffEntryProps {
directory: string;
file: FileEntry;
layout: 'inline' | 'side-by-side';
wrapLines: boolean;
scrollRootRef: React.RefObject;
isSelected: boolean;
onSelect: (path: string) => void;
registerSectionRef: (path: string, node: HTMLDivElement | null) => void;
/** Start collapsed to reduce memory with many files */
defaultCollapsed?: boolean;
expandRequestPath?: string | null;
expandRequestNonce?: number;
}
const MultiFileDiffEntry = React.memo(({
directory,
file,
layout,
wrapLines,
scrollRootRef,
isSelected,
onSelect,
registerSectionRef,
defaultCollapsed = false,
expandRequestPath = null,
expandRequestNonce = 0,
}) => {
const { git } = useRuntimeAPIs();
const cachedDiff = useGitStore(
React.useCallback((state) => {
return state.directories.get(directory)?.diffCache.get(file.path) ?? null;
}, [directory, file.path])
);
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(null);
const [isLoading, setIsLoading] = React.useState(false);
const lastDiffRequestRef = React.useRef(null);
const sectionRef = React.useRef(null);
const descriptor = React.useMemo(() => describeChange(file), [file]);
const renderSideBySide = layout === 'side-by-side';
const diffData = React.useMemo(() => {
if (!cachedDiff) return null;
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary };
}, [cachedDiff]);
const setSectionRef = React.useCallback((node: HTMLDivElement | null) => {
sectionRef.current = node;
registerSectionRef(file.path, node);
}, [file.path, registerSectionRef]);
const handleOpenChange = React.useCallback((open: boolean) => {
setIsExpanded(open);
if (open) {
setHasBeenVisible(true);
}
}, []);
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 (!isExpanded || !hasBeenVisible) return;
if (!directory || diffData) {
lastDiffRequestRef.current = null;
setIsLoading(false);
return;
}
const requestKey = `${directory}::${file.path}::${diffRetryNonce}`;
if (lastDiffRequestRef.current === requestKey) {
return;
}
lastDiffRequestRef.current = requestKey;
setDiffLoadError(null);
setIsLoading(true);
let cancelled = false;
void (async () => {
try {
const fetchPromise = git.getGitFileDiff(directory, { path: file.path });
const timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
});
const response = await Promise.race([fetchPromise, timeoutPromise]);
if (cancelled) return;
setDiff(directory, file.path, {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
});
setIsLoading(false);
} catch (error) {
if (cancelled) return;
const message = error instanceof Error ? error.message : String(error);
setDiffLoadError(message);
setIsLoading(false);
}
})();
return () => {
cancelled = true;
if (lastDiffRequestRef.current === requestKey) {
lastDiffRequestRef.current = null;
}
};
}, [directory, diffData, diffRetryNonce, file.path, git, hasBeenVisible, isExpanded, setDiff]);
const handleToggle = React.useCallback(() => {
handleOpenChange(!isExpanded);
handleSelect();
}, [handleOpenChange, handleSelect, isExpanded]);
return (
{isExpanded && (
{diffLoadError ? (
Failed to load diff
{diffLoadError}
) : null}
{isLoading && !diffData && !diffLoadError ? (
Loading diff…
) : null}
{diffData ? (
) : null}
)}
);
});
interface DiffViewProps {
hideStackedFileSidebar?: boolean;
stackedDefaultCollapsedAll?: boolean;
hideFileSelector?: boolean;
pinSelectedFileHeaderToTopOnNavigate?: boolean;
}
export const DiffView: React.FC = ({
hideStackedFileSidebar = false,
stackedDefaultCollapsedAll = false,
hideFileSelector = false,
pinSelectedFileHeaderToTopOnNavigate = false,
}) => {
const { git } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const { screenWidth, isMobile } = useDeviceInfo();
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
const status = useGitStatus(effectiveDirectory ?? null);
const isLoadingStatus = useGitStore((state) => state.isLoadingStatus);
const { setActiveDirectory, fetchStatus, setDiff } = useGitStore();
const [selectedFile, setSelectedFile] = React.useState(null);
const [stackedExpandTarget, setStackedExpandTarget] = React.useState(null);
const [stackedExpandRequestNonce, setStackedExpandRequestNonce] = React.useState(0);
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState(null);
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [diffLoadError, setDiffLoadError] = React.useState(null);
const lastDiffRequestRef = React.useRef(null);
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference);
const diffFileLayout = useUIStore((state) => state.diffFileLayout);
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
const diffWrapLinesStore = useUIStore((state) => state.diffWrapLines);
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
const diffViewMode = useUIStore((state) => state.diffViewMode);
const setDiffViewMode = useUIStore((state) => state.setDiffViewMode);
// Default to wrap on mobile
const diffWrapLines = isMobile || diffWrapLinesStore;
const isStackedView = diffViewMode === 'stacked';
const isMobileLayout = isMobile || screenWidth <= 768;
const showFileSidebar = !hideStackedFileSidebar && !isMobileLayout && screenWidth >= 1024;
const diffScrollRef = React.useRef(null);
const fileSectionRefs = React.useRef(new Map());
const pendingScrollTargetRef = React.useRef(null);
const pendingScrollFrameRef = React.useRef(null);
const shouldPinAfterAlignRef = React.useRef(false);
React.useEffect(() => {
if (!pinSelectedFileHeaderToTopOnNavigate || !isStackedView || !pinnedStackedTarget) {
return;
}
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;
}
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);
};
}, [isStackedView, pinSelectedFileHeaderToTopOnNavigate, pinnedStackedTarget]);
const changedFiles: FileEntry[] = React.useMemo(() => {
if (!status?.files) return [];
const diffStats = status.diffStats ?? {};
return status.files
.map((file) => ({
...file,
insertions: diffStats[file.path]?.insertions ?? 0,
deletions: diffStats[file.path]?.deletions ?? 0,
isNew: isNewStatusFile(file),
}))
.sort((a, b) => a.path.localeCompare(b.path));
}, [status]);
const selectedFileEntry = React.useMemo(() => {
if (!selectedFile) return null;
return changedFiles.find((file) => file.path === selectedFile) ?? null;
}, [changedFiles, selectedFile]);
const getLayoutForFile = React.useCallback((file: FileEntry): 'inline' | 'side-by-side' => {
const override = diffFileLayout[file.path];
if (override) return override;
if (diffLayoutPreference === 'inline') {
return 'inline';
}
if (diffLayoutPreference === 'side-by-side') {
return 'side-by-side';
}
const isNarrow = screenWidth < SIDE_BY_SIDE_MIN_WIDTH;
if (file.isNew || isNarrow) {
return 'inline';
}
return 'side-by-side';
}, [diffFileLayout, diffLayoutPreference, screenWidth]);
const currentLayoutForSelectedFile = React.useMemo<'inline' | 'side-by-side' | null>(() => {
if (!selectedFileEntry) return null;
return getLayoutForFile(selectedFileEntry);
}, [getLayoutForFile, selectedFileEntry]);
// Fetch git status on mount
React.useEffect(() => {
if (effectiveDirectory) {
setActiveDirectory(effectiveDirectory);
const dirState = useGitStore.getState().directories.get(effectiveDirectory);
if (!dirState?.status) {
fetchStatus(effectiveDirectory, git);
}
}
}, [effectiveDirectory, setActiveDirectory, fetchStatus, git]);
// Handle pending diff file from external navigation
React.useEffect(() => {
if (pendingDiffFile) {
setSelectedFile(pendingDiffFile);
setPendingDiffFile(null);
if (isStackedView) {
shouldPinAfterAlignRef.current = true;
pendingScrollTargetRef.current = pendingDiffFile;
setStackedExpandTarget(pendingDiffFile);
setStackedExpandRequestNonce((nonce) => nonce + 1);
}
}
}, [isStackedView, pendingDiffFile, setPendingDiffFile]);
// Auto-select first file (skip if we have a pending file to consume)
React.useEffect(() => {
if (!selectedFile && !pendingDiffFile && changedFiles.length > 0) {
setSelectedFile(changedFiles[0].path);
}
}, [changedFiles, selectedFile, pendingDiffFile]);
// Clear selection if file no longer exists
React.useEffect(() => {
if (selectedFile && changedFiles.length > 0) {
const stillExists = changedFiles.some((f) => f.path === selectedFile);
if (!stillExists) {
setSelectedFile(changedFiles[0]?.path ?? null);
}
}
}, [changedFiles, selectedFile]);
const registerSectionRef = React.useCallback((path: string, node: HTMLDivElement | null) => {
const map = fileSectionRefs.current;
if (node) {
map.set(path, node);
} else {
map.delete(path);
}
}, []);
type ScrollToFileResult = {
ok: boolean;
aligned: boolean;
didMove: boolean;
atScrollLimit: boolean;
delta: number;
};
const scrollToFile = React.useCallback((path: string): ScrollToFileResult => {
const node = fileSectionRefs.current.get(path);
const scrollRoot = diffScrollRef.current;
if (!node || !scrollRoot) {
return { ok: false, aligned: false, didMove: false, atScrollLimit: false, delta: 0 };
}
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 };
}, []);
React.useEffect(() => {
if (!isStackedView) {
pendingScrollTargetRef.current = null;
shouldPinAfterAlignRef.current = false;
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
}
return;
}
const target = pendingScrollTargetRef.current;
if (!target) return;
let attempts = 0;
const maxAttempts = 120;
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;
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;
}
const currentTarget = pendingScrollTargetRef.current;
if (!currentTarget) {
cancelPending();
pendingScrollFrameRef.current = null;
return;
}
ignoreNextScrollEvents += 1;
const result = scrollToFile(currentTarget);
if (!result.ok) {
ignoreNextScrollEvents = Math.max(0, ignoreNextScrollEvents - 1);
attempts += 1;
if (attempts < maxAttempts) {
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
} else {
cancelPending();
pendingScrollFrameRef.current = null;
}
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);
}
cancelPending();
};
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
return () => {
cancelled = true;
removeListeners();
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
}
};
}, [isStackedView, pinSelectedFileHeaderToTopOnNavigate, scrollToFile, selectedFile, stackedExpandRequestNonce]);
const handleSelectFile = React.useCallback((value: string) => {
setSelectedFile(value);
}, []);
const handleSelectFileAndScroll = React.useCallback((value: string) => {
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
}
pendingScrollTargetRef.current = null;
setSelectedFile(value);
if (!isStackedView) {
shouldPinAfterAlignRef.current = false;
return;
}
shouldPinAfterAlignRef.current = true;
pendingScrollTargetRef.current = value;
scrollToFile(value);
}, [isStackedView, scrollToFile]);
const handleDiffViewModeChange = React.useCallback((mode: DiffTabViewMode) => {
setDiffViewMode(mode);
if (mode === 'stacked' && selectedFile) {
const result = scrollToFile(selectedFile);
if (!result.aligned) {
pendingScrollTargetRef.current = selectedFile;
}
}
}, [scrollToFile, selectedFile, setDiffViewMode]);
const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => {
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
if (isStackedView) {
changedFiles.forEach((file) => {
setDiffFileLayout(file.path, nextLayout);
});
return;
}
if (!selectedFileEntry) return;
setDiffFileLayout(selectedFileEntry.path, nextLayout);
}, [changedFiles, isStackedView, selectedFileEntry, setDiffFileLayout]);
const renderSideBySide = (currentLayoutForSelectedFile ?? 'side-by-side') === 'side-by-side';
const showFileSelector = !hideFileSelector && (!isStackedView || !showFileSidebar);
const selectedCachedDiff = useGitStore(React.useCallback((state) => {
if (!effectiveDirectory || !selectedFile) return null;
return state.directories.get(effectiveDirectory)?.diffCache.get(selectedFile) ?? null;
}, [effectiveDirectory, selectedFile]));
const selectedDiffData = React.useMemo(() => {
if (!selectedCachedDiff) return null;
return { original: selectedCachedDiff.original, modified: selectedCachedDiff.modified, isBinary: selectedCachedDiff.isBinary };
}, [selectedCachedDiff]);
const hasCurrentDiff = !!selectedCachedDiff;
const isCurrentFileLoading = !isStackedView && !!selectedFile && !hasCurrentDiff;
React.useEffect(() => {
if (isStackedView) {
return;
}
setDiffLoadError(null);
if (!effectiveDirectory || !selectedFile) {
lastDiffRequestRef.current = null;
return;
}
if (selectedCachedDiff) {
lastDiffRequestRef.current = null;
return;
}
const requestKey = `${effectiveDirectory}::${selectedFile}::${diffRetryNonce}`;
if (lastDiffRequestRef.current === requestKey) {
return;
}
lastDiffRequestRef.current = requestKey;
let cancelled = false;
void (async () => {
try {
const fetchPromise = git.getGitFileDiff(effectiveDirectory, { path: selectedFile });
const timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
});
const response = await Promise.race([fetchPromise, timeoutPromise]);
if (cancelled) return;
setDiff(effectiveDirectory, selectedFile, {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
});
} catch (error) {
if (cancelled) return;
const message = error instanceof Error ? error.message : String(error);
setDiffLoadError(message);
}
})();
return () => {
cancelled = true;
if (lastDiffRequestRef.current === requestKey) {
// Allow a retry if this request was cancelled due to directory/path churn.
lastDiffRequestRef.current = null;
}
};
}, [effectiveDirectory, isStackedView, selectedFile, selectedCachedDiff, git, setDiff, diffRetryNonce]);
// Render only the selected diff viewer to prevent memory bloat with many files
const renderSelectedDiffViewer = () => {
if (!effectiveDirectory || !selectedFile || !selectedDiffData) return null;
return (
);
};
const renderStackedDiffView = () => {
if (!effectiveDirectory) return null;
const defaultExpandedCount = getStackedViewDefaultExpandedCount(changedFiles.length);
return (
{showFileSidebar && (
Files
{changedFiles.length}
)}
{changedFiles.map((file, index) => (
= defaultExpandedCount}
expandRequestPath={stackedExpandTarget}
expandRequestNonce={stackedExpandRequestNonce}
/>
))}
);
};
const renderContent = () => {
if (!effectiveDirectory) {
return (
Select a session directory to view diffs
);
}
if (isLoadingStatus && !status) {
return (
Loading repository status…
);
}
if (isGitRepo === false) {
return (
Not a git repository. Use the Git tab to initialize or change directories.
);
}
if (changedFiles.length === 0) {
return (
Working tree clean — no changes to display
);
}
if (isStackedView) {
return renderStackedDiffView();
}
return (
{renderSelectedDiffViewer()}
{isCurrentFileLoading && !hasCurrentDiff && (
{diffLoadError ? (
Failed to load diff
{diffLoadError}
) : (
<>
Loading diff…
>
)}
)}
);
};
return (
{!isMobile && (
{isLoadingStatus && !status
? 'Loading changes…'
: `${changedFiles.length} ${changedFiles.length === 1 ? 'file' : 'files'} changed`}
)}
{!isMobileLayout && (
)}
{showFileSelector && (
)}
{selectedFileEntry && (
)}
{selectedFileEntry && currentLayoutForSelectedFile && (
)}
{renderContent()}
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const useDiffFileCount = (): number => {
const { git } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const { setActiveDirectory, fetchStatus } = useGitStore();
const fileCount = useGitFileCount(effectiveDirectory ?? null);
React.useEffect(() => {
if (effectiveDirectory) {
setActiveDirectory(effectiveDirectory);
const dirState = useGitStore.getState().directories.get(effectiveDirectory);
if (!dirState?.status) {
fetchStatus(effectiveDirectory, git);
}
}
}, [effectiveDirectory, setActiveDirectory, fetchStatus, git]);
return fileCount;
};