import { Diff } from 'shared/types'; import { DiffModeEnum, DiffView, SplitSide } from '@git-diff-view/react'; import { generateDiffFile, type DiffFile } from '@git-diff-view/file'; import { useMemo } from 'react'; import { useUserSystem } from '@/components/config-provider'; import { getHighLightLanguageFromPath } from '@/utils/extToLanguage'; import { getActualTheme } from '@/utils/theme'; import { stripLineEnding } from '@/utils/string'; import { Button } from '@/components/ui/button'; import { ChevronRight, ChevronUp, Trash2, ArrowLeftRight, FilePlus2, PencilLine, Copy, Key, ExternalLink, MessageSquare, } from 'lucide-react'; import '@/styles/diff-style-overrides.css'; import { attemptsApi } from '@/lib/api'; import type { TaskAttempt } from 'shared/types'; import { useReview, type ReviewDraft, type ReviewComment, } from '@/contexts/ReviewProvider'; import { CommentWidgetLine } from '@/components/diff/CommentWidgetLine'; import { ReviewCommentRenderer } from '@/components/diff/ReviewCommentRenderer'; import { useDiffViewMode, useIgnoreWhitespaceDiff, } from '@/stores/useDiffViewStore'; import { useProject } from '@/contexts/project-context'; type Props = { diff: Diff; expanded: boolean; onToggle: () => void; selectedAttempt: TaskAttempt | null; }; function labelAndIcon(diff: Diff) { const c = diff.change; if (c === 'deleted') return { label: 'Deleted', Icon: Trash2 }; if (c === 'renamed') return { label: 'Renamed', Icon: ArrowLeftRight }; if (c === 'added') return { label: undefined as string | undefined, Icon: FilePlus2 }; if (c === 'copied') return { label: 'Copied', Icon: Copy }; if (c === 'permissionChange') return { label: 'Permission Changed', Icon: Key }; return { label: undefined as string | undefined, Icon: PencilLine }; } function readPlainLine( diffFile: DiffFile | null, lineNumber: number, side: SplitSide ) { if (!diffFile) return undefined; try { const rawLine = side === SplitSide.old ? diffFile.getOldPlainLine(lineNumber) : diffFile.getNewPlainLine(lineNumber); if (rawLine?.value === undefined) return undefined; return stripLineEnding(rawLine.value); } catch (error) { console.error('Failed to read line content for review comment', error); return undefined; } } export default function DiffCard({ diff, expanded, onToggle, selectedAttempt, }: Props) { const { config } = useUserSystem(); const theme = getActualTheme(config?.theme); const { comments, drafts, setDraft } = useReview(); const globalMode = useDiffViewMode(); const ignoreWhitespace = useIgnoreWhitespaceDiff(); const { projectId } = useProject(); const oldName = diff.oldPath || undefined; const newName = diff.newPath || oldName || 'unknown'; const oldLang = getHighLightLanguageFromPath(oldName || newName || '') || 'plaintext'; const newLang = getHighLightLanguageFromPath(newName || oldName || '') || 'plaintext'; const { label, Icon } = labelAndIcon(diff); const isOmitted = !!diff.contentOmitted; // Build a diff from raw contents so the viewer can expand beyond hunks const oldContentSafe = diff.oldContent || ''; const newContentSafe = diff.newContent || ''; const isContentEqual = oldContentSafe === newContentSafe; const diffOptions = useMemo( () => (ignoreWhitespace ? { ignoreWhitespace: true as const } : undefined), [ignoreWhitespace] ); const diffFile = useMemo(() => { if (isContentEqual || isOmitted) return null; try { const oldFileName = oldName || newName || 'unknown'; const newFileName = newName || oldName || 'unknown'; const file = generateDiffFile( oldFileName, oldContentSafe, newFileName, newContentSafe, oldLang, newLang, diffOptions ); file.initRaw(); return file; } catch (e) { console.error('Failed to build diff for view', e); return null; } }, [ isContentEqual, isOmitted, oldName, newName, oldLang, newLang, oldContentSafe, newContentSafe, diffOptions, ]); const add = isOmitted ? (diff.additions ?? 0) : (diffFile?.additionLength ?? 0); const del = isOmitted ? (diff.deletions ?? 0) : (diffFile?.deletionLength ?? 0); // Review functionality const filePath = newName || oldName || 'unknown'; const commentsForFile = useMemo( () => comments.filter((c) => c.filePath === filePath), [comments, filePath] ); // Transform comments to git-diff-view extendData format const extendData = useMemo(() => { const oldFileData: Record = {}; const newFileData: Record = {}; commentsForFile.forEach((comment) => { const lineKey = String(comment.lineNumber); if (comment.side === SplitSide.old) { oldFileData[lineKey] = { data: comment }; } else { newFileData[lineKey] = { data: comment }; } }); return { oldFile: oldFileData, newFile: newFileData, }; }, [commentsForFile]); const handleAddWidgetClick = (lineNumber: number, side: SplitSide) => { const widgetKey = `${filePath}-${side}-${lineNumber}`; const codeLine = readPlainLine(diffFile, lineNumber, side); const draft: ReviewDraft = { filePath, side, lineNumber, text: '', ...(codeLine !== undefined ? { codeLine } : {}), }; setDraft(widgetKey, draft); }; const renderWidgetLine = (props: { side: SplitSide; lineNumber: number; onClose: () => void; }) => { const widgetKey = `${filePath}-${props.side}-${props.lineNumber}`; const draft = drafts[widgetKey]; if (!draft) return null; return ( ); }; const renderExtendLine = (lineData: { data: ReviewComment }) => { return ( ); }; // Title row const title = (

{label && {label}} {diff.change === 'renamed' && oldName ? ( {oldName} {newName} ) : ( {newName} )} +{add} -{del} {commentsForFile.length > 0 && ( {commentsForFile.length} )}

); const handleOpenInIDE = async () => { if (!selectedAttempt?.id) return; try { const openPath = newName || oldName; const response = await attemptsApi.openEditor( selectedAttempt.id, undefined, openPath || undefined ); // If a URL is returned, open it in a new window/tab if (response.url) { window.open(response.url, '_blank'); } } catch (err) { console.error('Failed to open file in IDE:', err); } }; const expandable = true; return (
{expandable && ( )} {title}
{expanded && diffFile && (
)} {expanded && !diffFile && (
{isOmitted ? 'Content omitted due to file size. Open in editor to view.' : isContentEqual ? diff.change === 'renamed' ? 'File renamed with no content changes.' : diff.change === 'permissionChange' ? 'File permission changed.' : 'No content changes to display.' : 'Failed to render diff for this file.'}
)}
); }