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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; 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 }; 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 {`Original:
)} {hasModified && (
{hasOriginal ? 'Modified' : 'New'} {`Modified:
)}
); }); 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 {`Original:
)} {hasModified && (
{hasOriginal ? 'Modified' : 'New'} {`Modified:
)}
); }); 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 (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] ); // 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 DiffViewerEntryProps { directory: string; filePath: string; isVisible: boolean; renderSideBySide: boolean; wrapLines: boolean; } const DiffViewerEntry = React.memo(({ directory, filePath, isVisible, renderSideBySide, wrapLines, }) => { const cachedDiff = useGitStore( React.useCallback((state) => { return state.directories.get(directory)?.diffCache.get(filePath) ?? null; }, [directory, filePath]) ); const diffData = React.useMemo(() => { if (!cachedDiff) return null; return { original: cachedDiff.original, modified: cachedDiff.modified }; }, [cachedDiff]); if (!diffData) return null; 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; } const MultiFileDiffEntry = React.memo(({ directory, file, layout, wrapLines, scrollRootRef, isSelected, onSelect, registerSectionRef, defaultCollapsed = false, }) => { 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 }; }, [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 (!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 ?? '', }); 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]); return (
{isExpanded ? ( ) : ( )} {descriptor.code} {file.path}
{formatDiffTotals(file.insertions, file.deletions)} { const nextLayout: 'inline' | 'side-by-side' = mode === 'side-by-side' ? 'side-by-side' : 'inline'; setDiffFileLayout(file.path, nextLayout); }} className="opacity-70" />
{diffLoadError ? (
Failed to load diff
{diffLoadError}
) : null} {isLoading && !diffData && !diffLoadError ? (
Loading diff…
) : null} {isExpanded && diffData ? ( ) : null}
); }); export const DiffView: React.FC = () => { 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 [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 = !isMobileLayout && screenWidth >= 1024; const diffScrollRef = React.useRef(null); const fileSectionRefs = React.useRef(new Map()); const pendingScrollTargetRef = React.useRef(null); 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) { pendingScrollTargetRef.current = pendingDiffFile; } } }, [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]); React.useEffect(() => { if (!isStackedView) { pendingScrollTargetRef.current = null; return; } const target = pendingScrollTargetRef.current; if (!target) return; const node = fileSectionRefs.current.get(target); if (!node) return; node.scrollIntoView({ behavior: 'smooth', block: 'start' }); pendingScrollTargetRef.current = null; }, [changedFiles, isStackedView]); // 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); } }, []); const scrollToFile = React.useCallback((path: string, behavior: ScrollBehavior = 'smooth') => { const node = fileSectionRefs.current.get(path); if (!node) return false; node.scrollIntoView({ behavior, block: 'start' }); return true; }, []); const handleSelectFile = React.useCallback((value: string) => { setSelectedFile(value); }, []); const handleSelectFileAndScroll = React.useCallback((value: string) => { setSelectedFile(value); if (isStackedView && !scrollToFile(value)) { pendingScrollTargetRef.current = value; } }, [isStackedView, scrollToFile]); const handleDiffViewModeChange = React.useCallback((mode: DiffTabViewMode) => { setDiffViewMode(mode); if (mode === 'stacked' && selectedFile && !scrollToFile(selectedFile, 'auto')) { 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 = !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 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 ?? '', }); } 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) return null; return ( ); }; const renderStackedDiffView = () => { if (!effectiveDirectory) return null; const defaultExpandedCount = getStackedViewDefaultExpandedCount(changedFiles.length); return (
{showFileSidebar && (
Files {changedFiles.length}
)}
{changedFiles.map((file, index) => ( = defaultExpandedCount} /> ))}
); }; 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; };