'use client'; import { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { DiffView, DiffModeEnum, SplitSide } from '@git-diff-view/react'; import { generateDiffFile } from '@git-diff-view/file'; import { useTheme } from 'next-themes'; import { Button } from '@kandev/ui/button'; import { Badge } from '@kandev/ui/badge'; import { IconLayoutColumns, IconLayoutRows } from '@tabler/icons-react'; import { cn } from '@/lib/utils'; import { useDiffComments } from './use-diff-comments'; import { InlineCommentWidget } from './comment-widget'; import { CommentRenderer } from './comment-renderer'; import { DragSelectionOverlay } from './selection-overlay'; import { CommentRangeIndicator } from './comment-indicator'; import { PositionedCommentWidget } from './multi-line-comment'; import { getLineInfoFromElement } from './dom-utils'; import type { GitDiffViewerProps, ExtendLineData, DragSelectionState } from './types'; import '@git-diff-view/react/styles/diff-view.css'; import './git-diff-viewer.css'; export function GitDiffViewer({ oldContent, newContent, filePath, language = 'typescript', defaultViewMode = 'split', showToolbar = true, enableComments = true, comments: externalComments, onCommentAdd, onCommentDelete, className, }: GitDiffViewerProps) { const { resolvedTheme } = useTheme(); const [viewMode, setViewMode] = useState<'split' | 'unified'>(defaultViewMode); const [widgetStartLines, setWidgetStartLines] = useState>({}); const widgetStartLinesRef = useRef(widgetStartLines); useEffect(() => { widgetStartLinesRef.current = widgetStartLines; }, [widgetStartLines]); // Drag selection state for multi-line comments const [dragSelection, setDragSelection] = useState(null); const [showDragWidget, setShowDragWidget] = useState(false); const justCompletedMultiLineRef = useRef(false); const wrapperRef = useRef(null); // Comment management const { comments, addComment, deleteComment } = useDiffComments({ filePath, externalComments, onCommentAdd, onCommentDelete, }); const diffModeEnum = viewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified; const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light'; // Generate diff file const diffFile = useMemo(() => { try { const file = generateDiffFile(filePath, oldContent, filePath, newContent, language, language); file.initRaw(); return file; } catch (e) { console.error('Failed to generate diff:', e); return null; } }, [filePath, oldContent, newContent, language]); // Build extendData for displaying existing comments const extendData = useMemo(() => { const oldFileData: Record = {}; const newFileData: Record = {}; comments.forEach((comment) => { const lineKey = String(comment.endLine); const entry: ExtendLineData = { type: 'comment', comment }; if (comment.side === SplitSide.old) { oldFileData[lineKey] = { data: entry }; } else { newFileData[lineKey] = { data: entry }; } }); return { oldFile: oldFileData, newFile: newFileData }; }, [comments]); // Mouse handlers for drag selection const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (!enableComments || e.button !== 0) return; const target = e.target as HTMLElement; const lineInfo = getLineInfoFromElement(target, wrapperRef.current, viewMode); if (lineInfo) { setDragSelection({ startLine: lineInfo.lineNumber, endLine: lineInfo.lineNumber, side: lineInfo.side, isActive: true, }); setShowDragWidget(false); justCompletedMultiLineRef.current = false; } }, [enableComments, viewMode] ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (!dragSelection?.isActive) return; const target = e.target as HTMLElement; const lineInfo = getLineInfoFromElement(target, wrapperRef.current, viewMode); if (lineInfo && lineInfo.side === dragSelection.side && lineInfo.lineNumber !== dragSelection.endLine) { setDragSelection((prev) => (prev ? { ...prev, endLine: lineInfo.lineNumber } : null)); } }, [dragSelection, viewMode] ); const handleMouseUp = useCallback(() => { if (!dragSelection?.isActive) return; const { startLine, endLine, side } = dragSelection; const rangeStart = Math.min(startLine, endLine); const rangeEnd = Math.max(startLine, endLine); if (rangeStart !== rangeEnd) { setDragSelection({ startLine: rangeStart, endLine: rangeEnd, side, isActive: false }); setShowDragWidget(true); justCompletedMultiLineRef.current = true; } else { setDragSelection(null); } }, [dragSelection]); const handleMouseLeave = useCallback(() => { if (dragSelection?.isActive) { setDragSelection(null); } }, [dragSelection]); // Prevent library's single-line widget after multi-line selection const handleClick = useCallback((e: React.MouseEvent) => { if (justCompletedMultiLineRef.current) { e.stopPropagation(); e.preventDefault(); justCompletedMultiLineRef.current = false; } }, []); // Handle widget line rendering for single-line comments const renderWidgetLine = useCallback( (props: { side: SplitSide; lineNumber: number; onClose: () => void }) => { if (!diffFile || !enableComments) return null; const widgetKey = `${filePath}-${props.side}-${props.lineNumber}`; const startLine = widgetStartLinesRef.current[widgetKey] ?? props.lineNumber; return ( { addComment(comment.startLine, comment.endLine, comment.side, comment.content); setWidgetStartLines((prev) => { const next = { ...prev }; delete next[widgetKey]; return next; }); props.onClose(); }} onCancel={() => { setWidgetStartLines((prev) => { const next = { ...prev }; delete next[widgetKey]; return next; }); props.onClose(); }} onStartLineChange={(newStartLine) => { setWidgetStartLines((prev) => ({ ...prev, [widgetKey]: newStartLine })); }} /> ); }, [filePath, diffFile, viewMode, enableComments, addComment] ); // Render existing comments inline const renderExtendLine = useCallback( (lineData: { data: ExtendLineData }) => { if (!lineData.data) return null; return deleteComment(id)} />; }, [deleteComment] ); if (!diffFile) { return (
Failed to generate diff
); } return (
{showToolbar && (
{filePath}
)} {/* Outer container for positioning widget outside overflow */}
{/* Comment range indicators */} {enableComments && comments.map((comment) => ( ))} {/* Drag selection overlay */} {enableComments && dragSelection && diffFile && ( )} {}} renderWidgetLine={renderWidgetLine} extendData={extendData} renderExtendLine={renderExtendLine} />
{/* Multi-line comment widget - outside overflow container */} {enableComments && showDragWidget && dragSelection && !dragSelection.isActive && diffFile && ( { addComment(dragSelection.startLine, dragSelection.endLine, dragSelection.side, content); setDragSelection(null); setShowDragWidget(false); }} onCancel={() => { setDragSelection(null); setShowDragWidget(false); }} /> )}
); }