import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent'; import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection'; import { useStore } from '@renderer/store'; import { acceptAllChunks, getChunks, rejectAllChunks, replayHunkDecisionsSmart, } from './CodeMirrorDiffUtils'; import { FileSectionDiff } from './FileSectionDiff'; import { FileSectionHeader } from './FileSectionHeader'; import { FullDiffLoadingBanner } from './FullDiffLoadingBanner'; import type { EditorView } from '@codemirror/view'; import type { FileChangeWithContent, HunkDecision } from '@shared/types'; import type { EditorSelectionInfo } from '@shared/types/editor'; import type { FileChangeSummary } from '@shared/types/review'; interface ContinuousScrollViewProps { files: FileChangeSummary[]; fileContents: Record; fileContentsLoading: Record; globalDiffLoadingState?: { totalFilesCount: number; readyFilesCount: number; loadingFilesCount: number; snippetCount: number; activeFileName?: string; } | null; reviewExternalChangesByFile: Record; viewedSet: Set; editedContents: Record; hunkDecisions: Record; fileDecisions: Record; hunkContextHashesByFile: Record>; collapseUnchanged: boolean; applying: boolean; autoViewed: boolean; discardCounters: Record; onHunkAccepted: (filePath: string, hunkIndex: number) => void; onHunkRejected: (filePath: string, hunkIndex: number) => void; onFullyViewed: (filePath: string) => void; onContentChanged: (filePath: string, content: string) => void; onDiscard: (filePath: string) => void; onSave: (filePath: string) => void; onReloadFromDisk: (filePath: string) => void; onKeepDraft: (filePath: string) => void; onAcceptFile: (filePath: string) => void; onRejectFile: (filePath: string) => void; onRestoreMissingFile?: (filePath: string, content: string) => void; pathChangeLabels?: Record< string, { kind: 'deleted' } | { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string } >; /** Controlled collapsed state (persisted by parent). If omitted, component manages it locally. */ collapsedFiles?: Set; onToggleCollapse?: (filePath: string) => void; onVisibleFileChange: (filePath: string) => void; scrollContainerRef: React.RefObject; editorViewMapRef: React.MutableRefObject>; isProgrammaticScroll: React.RefObject; teamName: string; memberName: string | undefined; fetchFileContent: ( teamName: string, memberName: string | undefined, filePath: string ) => Promise; onSelectionChange?: (info: EditorSelectionInfo | null) => void; globalHunkOffsets?: Record; totalReviewHunks?: number; } export const ContinuousScrollView = ({ files, fileContents, fileContentsLoading, globalDiffLoadingState, reviewExternalChangesByFile, viewedSet, editedContents, hunkDecisions, fileDecisions, hunkContextHashesByFile, collapseUnchanged, applying, autoViewed, discardCounters, onHunkAccepted, onHunkRejected, onFullyViewed, onContentChanged, onDiscard, onSave, onReloadFromDisk, onKeepDraft, onAcceptFile, onRejectFile, onRestoreMissingFile, pathChangeLabels, collapsedFiles: collapsedFilesProp, onToggleCollapse: onToggleCollapseProp, onVisibleFileChange, scrollContainerRef, editorViewMapRef, isProgrammaticScroll, teamName, memberName, fetchFileContent, onSelectionChange, globalHunkOffsets, totalReviewHunks, }: ContinuousScrollViewProps): React.ReactElement => { const setFileChunkCount = useStore((s) => s.setFileChunkCount); const [localCollapsedFiles, setLocalCollapsedFiles] = useState>(() => new Set()); const collapsedFiles = collapsedFilesProp ?? localCollapsedFiles; const handleToggleCollapse = useCallback( (filePath: string) => { if (onToggleCollapseProp) { onToggleCollapseProp(filePath); return; } setLocalCollapsedFiles((prev) => { const next = new Set(prev); if (next.has(filePath)) { next.delete(filePath); } else { next.add(filePath); } return next; }); }, [onToggleCollapseProp] ); const filePaths = useMemo(() => files.map((f) => f.filePath), [files]); const { registerFileSectionRef } = useVisibleFileSection({ onVisibleFileChange, scrollContainerRef, isProgrammaticScroll, }); const { registerLazyRef } = useLazyFileContent({ teamName, memberName, filePaths, scrollContainerRef, fileContents, fileContentsLoading, fetchFileContent, enabled: true, }); // Combined ref callback: registers element in both scroll-spy and lazy-load observers const combinedRef = useCallback( (filePath: string) => { const sectionRef = registerFileSectionRef(filePath); const lazyRef = registerLazyRef(filePath); return (element: HTMLElement | null) => { sectionRef(element); lazyRef(element); }; }, [registerFileSectionRef, registerLazyRef] ); // Refs to avoid stale closures — decisions change frequently const fileDecisionsRef = useRef(fileDecisions); const hunkDecisionsRef = useRef(hunkDecisions); const hunkHashesRef = useRef(hunkContextHashesByFile); useEffect(() => { fileDecisionsRef.current = fileDecisions; hunkDecisionsRef.current = hunkDecisions; hunkHashesRef.current = hunkContextHashesByFile; }); // Track which views have already had decisions replayed to prevent // cascading re-replays on every render (useEffect in FileSectionDiff has no deps). // When a view is destroyed/recreated (discard, lazy remount), the identity changes // and replay runs once for the new instance. const replayedViewsRef = useRef(new Set()); const handleEditorViewReady = useCallback( (filePath: string, view: EditorView | null) => { if (view) { // Skip if this exact view instance was already processed if (editorViewMapRef.current.get(filePath) === view && replayedViewsRef.current.has(view)) { return; } editorViewMapRef.current.set(filePath, view); replayedViewsRef.current.add(view); // Store the actual CM chunk count (may differ from snippet count) const chunks = getChunks(view.state); if (chunks) { setFileChunkCount(filePath, chunks.chunks.length); } const fileDecision = fileDecisionsRef.current[filePath]; if (fileDecision === 'accepted' || fileDecision === 'rejected') { // Sync file-level "Accept All" / "Reject All" decisions requestAnimationFrame(() => { if (fileDecision === 'accepted') { acceptAllChunks(view); } else { rejectAllChunks(view); } }); } else { // Replay individual per-hunk decisions persisted from previous session requestAnimationFrame(() => { replayHunkDecisionsSmart( view, filePath, hunkDecisionsRef.current, hunkHashesRef.current[filePath] ); }); } } else { editorViewMapRef.current.delete(filePath); // Don't clean replayedViewsRef — stale entries are harmless (WeakSet-like behavior // is not needed since view instances are unique and old ones get GC'd) } }, [editorViewMapRef, setFileChunkCount] ); if (files.length === 0) { return (
No file changes detected
); } return (
{globalDiffLoadingState ? ( ) : null} {files.map((file) => { const filePath = file.filePath; const content = fileContents[filePath] ?? null; const hasContent = filePath in fileContents; const hasEdits = filePath in editedContents; const isViewed = viewedSet.has(filePath); const decision = fileDecisions[filePath]; const isCollapsed = collapsedFiles.has(filePath); return (
{!isCollapsed && ( )}
); })}
); };