import React from 'react'; import { RiArrowDownSLine, RiArrowRightSLine, RiEditLine, RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react'; import { useUIStore } from '@/stores/useUIStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useGitStore, useGitStatus, useIsGitRepo, useGitFileCount, useGitLoadingStatus } 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 { toast } from '@/components/ui'; 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'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard'; import { sessionEvents } from '@/lib/sessionEvents'; import { useI18n } from '@/lib/i18n'; import type { I18nKey } from '@/lib/i18n/store'; // 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; // 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(() => { const { t } = useI18n(); return (
{t('diffView.binary.unavailable')}
); }); type DiffTabViewMode = 'single' | 'stacked'; type ChangeDescriptor = { code: string; color: string; descriptionKey: I18nKey; }; const CHANGE_DESCRIPTORS: Record = { '?': { code: '?', color: 'var(--status-info)', descriptionKey: 'diffView.change.untracked' }, A: { code: 'A', color: 'var(--status-success)', descriptionKey: 'diffView.change.new' }, D: { code: 'D', color: 'var(--status-error)', descriptionKey: 'diffView.change.deleted' }, R: { code: 'R', color: 'var(--status-info)', descriptionKey: 'diffView.change.renamed' }, C: { code: 'C', color: 'var(--status-info)', descriptionKey: 'diffView.change.copied' }, M: { code: 'M', color: 'var(--status-warning)', descriptionKey: 'diffView.change.modified' }, }; const DEFAULT_CHANGE_DESCRIPTOR = CHANGE_DESCRIPTORS.M; const DIFF_VIEW_MODE_OPTIONS: Array<{ value: DiffTabViewMode; labelKey: I18nKey; descriptionKey: I18nKey; }> = [ { value: 'single', labelKey: 'diffView.mode.single.label', descriptionKey: 'diffView.mode.single.description', }, { value: 'stacked', labelKey: 'diffView.mode.stacked.label', descriptionKey: 'diffView.mode.stacked.description', }, ]; 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 isAbsolutePath = (value: string): boolean => { return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value); }; const toAbsolutePath = (directory: string, filePath: string): string => { const normalizedDirectory = directory.replace(/\\/g, '/').replace(/\/+$/g, ''); const normalizedFilePath = filePath.replace(/\\/g, '/'); if (isAbsolutePath(normalizedFilePath)) { return normalizedFilePath; } const trimmedFilePath = normalizedFilePath.replace(/^\/+/, ''); return normalizedDirectory ? `${normalizedDirectory}/${trimmedFilePath}` : trimmedFilePath; }; const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/, ''); const getFirstChangedModifiedLine = (original: string, modified: string): number => { const originalLines = original.split('\n'); const modifiedLines = modified.split('\n'); const sharedLength = Math.min(originalLines.length, modifiedLines.length); for (let index = 0; index < sharedLength; index += 1) { if (originalLines[index] !== modifiedLines[index]) { return index + 1; } } if (modifiedLines.length > originalLines.length) { return originalLines.length + 1; } if (originalLines.length > modifiedLines.length) { return Math.max(1, modifiedLines.length); } return 1; }; const getFirstVisibleModifiedLineFromPatch = (patch: string): number | null => { if (!patch) { return null; } const match = patch.match(/@@\s*-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/m); if (!match) { return null; } const parsed = Number.parseInt(match[1], 10); if (!Number.isFinite(parsed) || parsed < 1) { return null; } return parsed; }; 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 { t } = useI18n(); 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 ? ( <> {t('diffView.selector.viewMode')} onModeChange(value as DiffTabViewMode)} > {DIFF_VIEW_MODE_OPTIONS.map((option) => ( {t(option.labelKey)} ))} ) : 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 { t } = useI18n(); 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) => ( {t(option.labelKey)} ))} ); }); interface FileListProps { changedFiles: FileEntry[]; selectedFile: string | null; onSelectFile: (path: string) => void; } const FileList = React.memo(({ changedFiles, selectedFile, onSelectFile, }) => { const { t } = useI18n(); 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 { t } = useI18n(); 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 && (
{t('diffView.image.original')} {t('diffView.image.originalAlt',
)} {hasModified && (
{hasOriginal ? t('diffView.image.modified') : t('diffView.image.new')} {t('diffView.image.modifiedAlt',
)}
); }); interface InlineImageDiffViewerProps { filePath: string; diff: DiffData; renderSideBySide: boolean; } const InlineImageDiffViewer = React.memo(({ filePath, diff, renderSideBySide, }) => { const { t } = useI18n(); 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 && (
{t('diffView.image.original')} {t('diffView.image.originalAlt',
)} {hasModified && (
{hasOriginal ? t('diffView.image.modified') : t('diffView.image.new')} {t('diffView.image.modifiedAlt',
)}
); }); 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; showOpenInEditorAction?: boolean; isOpeningInEditor?: boolean; onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void; } const MultiFileDiffEntry = React.memo(({ directory, file, layout, wrapLines, scrollRootRef, isSelected, onSelect, registerSectionRef, defaultCollapsed = false, expandRequestPath = null, expandRequestNonce = 0, showOpenInEditorAction = false, isOpeningInEditor = false, onOpenInEditor, }) => { const { t } = useI18n(); 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 [forceRenderLarge, setForceRenderLarge] = 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; 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); }); void Promise.race([fetchPromise, timeoutPromise]) .then((response) => { 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 (
) : null} { const nextLayout: 'inline' | 'side-by-side' = mode === 'side-by-side' ? 'side-by-side' : 'inline'; setDiffFileLayout(file.path, nextLayout); }} className="opacity-70" />
{isExpanded && (
{diffLoadError ? (
Failed to load diff
{diffLoadError}
) : null} {isLoading && !diffData && !diffLoadError ? (
Loading diff…
) : null} {diffData && !forceRenderLarge && (file.insertions + file.deletions) > LARGE_DIFF_CHANGED_LINES ? (
Large diff ({file.insertions + file.deletions} changed lines)
Rendering may be slow. You can still view the diff by clicking below.
) : null} {diffData && (forceRenderLarge || (file.insertions + file.deletions) <= LARGE_DIFF_CHANGED_LINES) ? ( ) : null}
)}
); }); interface DiffViewProps { hideStackedFileSidebar?: boolean; stackedDefaultCollapsedAll?: boolean; hideFileSelector?: boolean; pinSelectedFileHeaderToTopOnNavigate?: boolean; showOpenInEditorAction?: boolean; } export const DiffView: React.FC = ({ hideStackedFileSidebar = false, stackedDefaultCollapsedAll = false, hideFileSelector = false, pinSelectedFileHeaderToTopOnNavigate = false, showOpenInEditorAction = false, }) => { const { t } = useI18n(); const { git, files } = useRuntimeAPIs(); const effectiveDirectory = useEffectiveDirectory(); const { screenWidth, isMobile } = useDeviceInfo(); const isGitRepo = useIsGitRepo(effectiveDirectory ?? null); const status = useGitStatus(effectiveDirectory ?? null); const isLoadingStatus = useGitLoadingStatus(effectiveDirectory ?? null); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const ensureStatus = useGitStore((state) => state.ensureStatus); const fetchStatus = useGitStore((state) => state.fetchStatus); const setDiff = useGitStore((state) => state.setDiff); 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); const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine); const diffWrapLines = 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]); // Ensure git status on mount React.useEffect(() => { if (effectiveDirectory) { setActiveDirectory(effectiveDirectory); void ensureStatus(effectiveDirectory, git); } }, [effectiveDirectory, setActiveDirectory, ensureStatus, git]); React.useEffect(() => { if (!effectiveDirectory) { return; } return sessionEvents.onGitRefreshHint((hint) => { if (normalizePath(hint.directory) !== normalizePath(effectiveDirectory)) { return; } void fetchStatus(effectiveDirectory, git); }); }, [effectiveDirectory, 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 [openingEditorFilePath, setOpeningEditorFilePath] = React.useState(null); const openFileInEditorAtChange = React.useCallback(async (filePath: string, cachedDiffData: DiffData | null) => { if (!effectiveDirectory || !filePath) { return; } setOpeningEditorFilePath(filePath); try { let targetLine: number | null = null; if (cachedDiffData && !cachedDiffData.isBinary && !isImageFile(filePath)) { targetLine = getFirstChangedModifiedLine(cachedDiffData.original, cachedDiffData.modified); } if (targetLine === null) { try { const patchResponse = await git.getGitDiff(effectiveDirectory, { path: filePath, contextLines: 3, }); targetLine = getFirstVisibleModifiedLineFromPatch(patchResponse.diff); } catch { targetLine = null; } } let diffForNavigation = cachedDiffData; if (targetLine === null || !diffForNavigation) { const response = await git.getGitFileDiff(effectiveDirectory, { path: filePath }); diffForNavigation = { original: response.original ?? '', modified: response.modified ?? '', isBinary: response.isBinary, }; setDiff(effectiveDirectory, filePath, diffForNavigation); } const resolvedTargetLine = targetLine ?? ((diffForNavigation.isBinary || isImageFile(filePath)) ? 1 : getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified)); const absolutePath = toAbsolutePath(effectiveDirectory, filePath); const openValidation = await validateContextFileOpen(files, absolutePath); if (!openValidation.ok) { toast.error(getContextFileOpenFailureMessage(openValidation.reason)); return; } openContextFileAtLine( effectiveDirectory, absolutePath, resolvedTargetLine, 1, ); } finally { setOpeningEditorFilePath((current) => (current === filePath ? null : current)); } }, [effectiveDirectory, files, git, openContextFileAtLine, setDiff]); const openSelectedFileInEditorAtChange = React.useCallback(async () => { if (!selectedFile) { return; } await openFileInEditorAtChange(selectedFile, selectedDiffData); }, [openFileInEditorAtChange, selectedDiffData, selectedFile]); const isOpeningSelectedInEditor = Boolean(selectedFile && openingEditorFilePath === selectedFile); 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; 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); }); void Promise.race([fetchPromise, timeoutPromise]) .then((response) => { 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 && (
{t('diffView.section.files')} {changedFiles.length}
)}
{changedFiles.map((file, index) => ( = defaultExpandedCount} expandRequestPath={stackedExpandTarget} expandRequestNonce={stackedExpandRequestNonce} showOpenInEditorAction={showOpenInEditorAction} isOpeningInEditor={openingEditorFilePath === file.path} onOpenInEditor={(filePath, diffData) => { void openFileInEditorAtChange(filePath, diffData); }} /> ))}
); }; const renderContent = () => { if (!effectiveDirectory) { return (
{t('diffView.state.selectSessionDirectory')}
); } if (isLoadingStatus && !status) { return (
{t('diffView.state.loadingRepositoryStatus')}
); } if (isGitRepo === false) { return (
{t('diffView.state.notGitRepository')}
); } if (changedFiles.length === 0) { return (
{t('diffView.state.cleanWorkingTree')}
); } if (isStackedView) { return renderStackedDiffView(); } return (
{renderSelectedDiffViewer()} {isCurrentFileLoading && !hasCurrentDiff && (
{diffLoadError ? (
{t('diffView.state.failedToLoadDiff')}
{diffLoadError}
) : ( <> {t('diffView.state.loadingDiff')} )}
)}
); }; return (
{!isMobile && (
{isLoadingStatus && !status ? t('diffView.state.loadingChanges') : (changedFiles.length === 1 ? t('diffView.summary.changedFilesSingle', { count: changedFiles.length }) : t('diffView.summary.changedFilesPlural', { count: changedFiles.length }))}
)} {!isMobileLayout && ( )} {showFileSelector && ( )}
{selectedFileEntry && ( )} {showOpenInEditorAction && selectedFileEntry && !isStackedView && ( )} {selectedFileEntry && currentLayoutForSelectedFile && ( )}
{renderContent()}
); }; // eslint-disable-next-line react-refresh/only-export-components export const useDiffFileCount = (): number => { const { git } = useRuntimeAPIs(); const effectiveDirectory = useEffectiveDirectory(); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const ensureStatus = useGitStore((state) => state.ensureStatus); const fileCount = useGitFileCount(effectiveDirectory ?? null); React.useEffect(() => { if (effectiveDirectory) { setActiveDirectory(effectiveDirectory); void ensureStatus(effectiveDirectory, git); } }, [effectiveDirectory, setActiveDirectory, ensureStatus, git]); return fileCount; };