'use client'; import { useState, useCallback, useEffect, useMemo, useRef, memo, type ReactNode } from 'react'; import { useTheme } from 'next-themes'; import { FileDiff } from '@pierre/diffs/react'; import { parsePatchFiles, parseDiffFromFile } from '@pierre/diffs'; import type { FileDiffOptions, FileDiffMetadata, DiffLineAnnotation, AnnotationSide, SelectedLineRange } from '@pierre/diffs'; import { cn } from '@kandev/ui/lib/utils'; import { IconPlus } from '@tabler/icons-react'; import type { FileDiffData, DiffComment } from '@/lib/diff/types'; import { buildDiffComment, useCommentActions } from '@/lib/diff/comment-utils'; import { FONT } from '@/lib/theme/colors'; import { useGlobalViewMode } from '@/hooks/use-global-view-mode'; import { useDiffComments } from './use-diff-comments'; import { CommentForm } from './comment-form'; import { CommentDisplay } from './comment-display'; import { useDiffHeaderToolbar } from './diff-header-toolbar'; import { HunkActionBar } from './hunk-action-bar'; /** * Check if Go code contains patterns that trigger catastrophic regex backtracking * in shiki's JavaScript regex engine. */ function hasProblematicGoPattern(content: string | undefined): boolean { if (!content) return false; const problematicPattern = /interface\{\}\s*`[^`]*`/; return problematicPattern.test(content); } function isGoFile(filePath: string): boolean { return filePath.endsWith('.go'); } export type RevertBlockInfo = { /** 1-based line number in the new file where additions start */ addStart: number; /** Number of addition lines to remove (0 for pure deletions) */ addCount: number; /** Original lines to restore (empty for pure additions) */ oldLines: string[]; }; interface DiffViewerProps { data: FileDiffData; enableComments?: boolean; sessionId?: string; onCommentAdd?: (comment: DiffComment) => void; onCommentDelete?: (commentId: string) => void; comments?: DiffComment[]; className?: string; compact?: boolean; hideHeader?: boolean; onOpenFile?: (filePath: string) => void; onRevert?: (filePath: string) => void; enableAcceptReject?: boolean; onRevertBlock?: (filePath: string, info: RevertBlockInfo) => Promise | void; wordWrap?: boolean; } type AnnotationMetadata = { type: 'comment' | 'new-comment-form' | 'hunk-actions'; comment?: DiffComment; isEditing?: boolean; changeBlockId?: string; }; function arePropsEqual(prevProps: DiffViewerProps, nextProps: DiffViewerProps): boolean { if (prevProps.data.filePath !== nextProps.data.filePath) return false; if (prevProps.data.diff !== nextProps.data.diff) return false; if (prevProps.data.oldContent !== nextProps.data.oldContent) return false; if (prevProps.data.newContent !== nextProps.data.newContent) return false; if (prevProps.enableComments !== nextProps.enableComments) return false; if (prevProps.sessionId !== nextProps.sessionId) return false; if (prevProps.compact !== nextProps.compact) return false; if (prevProps.hideHeader !== nextProps.hideHeader) return false; if (prevProps.className !== nextProps.className) return false; if (prevProps.onOpenFile !== nextProps.onOpenFile) return false; if (prevProps.onRevert !== nextProps.onRevert) return false; if (prevProps.enableAcceptReject !== nextProps.enableAcceptReject) return false; if (prevProps.onRevertBlock !== nextProps.onRevertBlock) return false; if (prevProps.wordWrap !== nextProps.wordWrap) return false; const prevComments = prevProps.comments; const nextComments = nextProps.comments; if (prevComments !== nextComments) { if (!prevComments || !nextComments) return false; if (prevComments.length !== nextComments.length) return false; for (let i = 0; i < prevComments.length; i++) { if (prevComments[i].id !== nextComments[i].id) return false; if (prevComments[i].annotation !== nextComments[i].annotation) return false; } } return true; } export const DiffViewer = memo(function DiffViewer({ data, enableComments = false, sessionId, onCommentAdd, onCommentDelete, comments: externalComments, className, compact = false, hideHeader = false, onOpenFile, onRevert, enableAcceptReject = false, onRevertBlock, wordWrap: wordWrapProp, }: DiffViewerProps) { const { resolvedTheme } = useTheme(); const [selectedLines, setSelectedLines] = useState(null); const [showCommentForm, setShowCommentForm] = useState(false); const [globalViewMode, setGlobalViewMode] = useGlobalViewMode(); const [wordWrapLocal, setWordWrap] = useState(false); const wordWrap = wordWrapProp ?? wordWrapLocal; const { comments: internalComments, addComment, removeComment, updateComment, editingCommentId, setEditingComment, } = useDiffComments({ sessionId: sessionId || '', filePath: data.filePath, diff: data.diff, newContent: data.newContent, oldContent: data.oldContent, }); const comments = externalComments || internalComments; // Parse initial diff metadata const initialDiffMetadata = useMemo(() => { let result: FileDiffMetadata | null = null; if (data.diff) { const parsed = parsePatchFiles(data.diff); result = parsed[0]?.files[0] ?? null; } else if (data.oldContent || data.newContent) { result = parseDiffFromFile( { name: data.filePath, contents: data.oldContent }, { name: data.filePath, contents: data.newContent } ); } if (result && isGoFile(data.filePath)) { const contentToCheck = data.newContent || data.oldContent || data.diff; if (hasProblematicGoPattern(contentToCheck)) { result = { ...result, lang: 'text' }; } } return result; }, [data.diff, data.oldContent, data.newContent, data.filePath]); const fileDiffMetadata = initialDiffMetadata; // Per-change-block revert info: changeBlockId → { addStart, addCount, oldLines } const revertInfoRef = useRef>(new Map()); const handleRevertBlock = useCallback( async (changeBlockId: string) => { const info = revertInfoRef.current.get(changeBlockId); if (!info) return; await onRevertBlock?.(data.filePath, info); }, [data.filePath, onRevertBlock] ); // Map from "side:lineNumber" → changeBlockId for hover detection const changeLineMapRef = useRef>(new Map()); const hideTimeoutRef = useRef | null>(null); // Annotations: comments, new comment form, and hunk accept/reject const { annotations, lineMap, revertMap } = useMemo(() => { const result: DiffLineAnnotation[] = comments.map((comment) => ({ side: comment.side, lineNumber: comment.endLine, metadata: { type: 'comment' as const, comment, isEditing: editingCommentId === comment.id, }, })); if (showCommentForm && selectedLines) { result.push({ side: (selectedLines.side || 'additions') as AnnotationSide, lineNumber: Math.max(selectedLines.start, selectedLines.end), metadata: { type: 'new-comment-form' as const }, }); } // Add undo annotations per change block within each hunk. // Also build a line → changeBlockId mapping for hover detection, // and a changeBlockId → RevertBlockInfo mapping for workspace revert. const newLineMap = new Map(); const newRevertMap = new Map(); if (enableAcceptReject && fileDiffMetadata) { let blockIdx = 0; for (let hi = 0; hi < fileDiffMetadata.hunks.length; hi++) { const hunk = fileDiffMetadata.hunks[hi]; if (hunk.additionCount === 0 && hunk.deletionCount === 0) continue; let addLine = hunk.additionStart; let delLine = hunk.deletionStart; let lastCtxAdd = addLine > 1 ? addLine - 1 : addLine; let lastCtxDel = delLine > 1 ? delLine - 1 : delLine; for (const content of hunk.hunkContent) { if (content.type === 'context') { const len = content.lines.length; lastCtxAdd = addLine + len - 1; lastCtxDel = delLine + len - 1; addLine += len; delLine += len; } else { const aLen = content.additions.length; const dLen = content.deletions.length; if (aLen > 0 || dLen > 0) { const cbId = `cb-${blockIdx++}`; const side: AnnotationSide = aLen > 0 ? 'additions' : 'deletions'; const lineNumber = side === 'additions' ? lastCtxAdd : lastCtxDel; result.push({ side, lineNumber, metadata: { type: 'hunk-actions', changeBlockId: cbId }, }); for (let l = 0; l < aLen; l++) newLineMap.set(`additions:${addLine + l}`, cbId); for (let l = 0; l < dLen; l++) newLineMap.set(`deletions:${delLine + l}`, cbId); newRevertMap.set(cbId, { addStart: addLine, addCount: aLen, // Pierre stores lines with trailing \n — strip them since // we splice into an array produced by split('\n') oldLines: content.deletions.map(l => l.replace(/\r?\n$/, '')), }); } addLine += aLen; delLine += dLen; } } } } return { annotations: result, lineMap: newLineMap, revertMap: newRevertMap }; }, [comments, editingCommentId, showCommentForm, selectedLines, enableAcceptReject, fileDiffMetadata]); // Sync maps to refs outside of render (avoids react-hooks/refs violation) useEffect(() => { changeLineMapRef.current = lineMap; revertInfoRef.current = revertMap; }, [lineMap, revertMap]); const handleLineSelectionEnd = useCallback( (range: SelectedLineRange | null) => { setSelectedLines(range); if (range && enableComments) { setShowCommentForm(true); } }, [enableComments] ); const handleCommentSubmit = useCallback( (content: string) => { if (!selectedLines) return; if (onCommentAdd && externalComments !== undefined) { onCommentAdd(buildDiffComment({ filePath: data.filePath, sessionId: sessionId || '', startLine: selectedLines.start, endLine: selectedLines.end, side: (selectedLines.side || 'additions') as DiffComment['side'], annotation: content, })); } else if (sessionId) { addComment(selectedLines, content); } setShowCommentForm(false); setSelectedLines(null); }, [selectedLines, sessionId, data.filePath, addComment, onCommentAdd, externalComments] ); const { handleCommentDelete, handleCommentUpdate } = useCommentActions({ removeComment, updateComment, setEditingComment, onCommentDelete, externalComments, }); // Show/hide undo buttons when hovering change lines (direct DOM, no re-renders) const wrapperRef = useRef(null); const activeBlockRef = useRef(null); const isHoveringButtonRef = useRef(false); const setBlockVisible = useCallback((cbId: string | null, visible: boolean) => { if (!cbId) return; const btn = wrapperRef.current?.querySelector(`[data-cb="${cbId}"] [data-undo-btn]`); if (btn instanceof HTMLElement) { btn.style.opacity = visible ? '1' : '0'; btn.style.pointerEvents = visible ? 'auto' : 'none'; } }, []); const showBlock = useCallback((cbId: string) => { if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } if (activeBlockRef.current === cbId) return; setBlockVisible(activeBlockRef.current, false); activeBlockRef.current = cbId; setBlockVisible(cbId, true); }, [setBlockVisible]); const hideBlock = useCallback(() => { if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = setTimeout(() => { if (isHoveringButtonRef.current) return; // Don't hide while mouse is on the button setBlockVisible(activeBlockRef.current, false); activeBlockRef.current = null; }, 200); }, [setBlockVisible]); const onButtonEnter = useCallback(() => { isHoveringButtonRef.current = true; if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } }, []); const onButtonLeave = useCallback(() => { isHoveringButtonRef.current = false; hideBlock(); }, [hideBlock]); const showBlockRef = useRef(showBlock); const hideBlockRef = useRef(hideBlock); useEffect(() => { showBlockRef.current = showBlock; }, [showBlock]); useEffect(() => { hideBlockRef.current = hideBlock; }, [hideBlock]); const onLineEnter = useCallback((props: { lineType?: string; lineNumber?: number; annotationSide?: string }) => { const { lineType, lineNumber, annotationSide } = props; if (!lineType?.startsWith('change-') || lineNumber == null) { hideBlockRef.current(); return; } const side = lineType === 'change-deletion' ? 'deletions' : 'additions'; const key = `${annotationSide ?? side}:${lineNumber}`; const cbId = changeLineMapRef.current.get(key); if (cbId) showBlockRef.current(cbId); else hideBlockRef.current(); }, []); const onLineLeave = useCallback(() => { hideBlockRef.current(); }, []); const renderAnnotation = useCallback( (annotation: DiffLineAnnotation): ReactNode => { const { type, comment, isEditing, changeBlockId } = annotation.metadata; if (type === 'hunk-actions' && changeBlockId) { return ( handleRevertBlock(changeBlockId)} onMouseEnter={onButtonEnter} onMouseLeave={onButtonLeave} /> ); } if (type === 'new-comment-form') { return (
{ setShowCommentForm(false); setSelectedLines(null); }} />
); } if (isEditing && comment) { return (
handleCommentUpdate(comment.id, content)} onCancel={() => setEditingComment(null)} isEditing />
); } if (comment) { return (
handleCommentDelete(comment.id)} onEdit={() => setEditingComment(comment.id)} showCode={false} />
); } return null; }, [setEditingComment, handleCommentDelete, handleCommentUpdate, handleCommentSubmit, handleRevertBlock, onButtonEnter, onButtonLeave] ); const toggleViewMode = useCallback( () => setGlobalViewMode(globalViewMode === 'split' ? 'unified' : 'split'), [globalViewMode, setGlobalViewMode] ); const toggleWordWrap = useCallback( () => setWordWrap((v) => !v), [] ); const renderHeaderMetadata = useDiffHeaderToolbar({ filePath: data.filePath, diff: data.diff, wordWrap, onToggleWordWrap: toggleWordWrap, viewMode: globalViewMode, onToggleViewMode: toggleViewMode, onOpenFile, onRevert, }); const renderHoverUtility = useCallback( (): ReactNode => { if (!enableComments) return null; return (
); }, [enableComments] ); const showHeader = !hideHeader && !compact; const options = useMemo>( () => ({ diffStyle: globalViewMode, themeType: resolvedTheme === 'dark' ? 'dark' : 'light', enableLineSelection: enableComments, hunkSeparators: 'simple', enableHoverUtility: enableComments, diffIndicators: 'none', onLineSelectionEnd: handleLineSelectionEnd, onLineEnter, onLineLeave, disableFileHeader: !showHeader, overflow: wordWrap ? 'wrap' : 'scroll', unsafeCSS: ` /* Override Pierre's inline theme — both CSS vars AND direct background-color */ pre[data-diffs] { background-color: var(--background) !important; --diffs-bg: var(--background) !important; --diffs-bg-context: var(--background) !important; --diffs-bg-buffer: var(--background) !important; --diffs-bg-separator: var(--card) !important; --diffs-bg-hover: var(--muted) !important; --diffs-fg: var(--foreground) !important; --diffs-fg-number: var(--muted-foreground) !important; --diffs-addition-color-override: rgb(var(--git-addition)) !important; --diffs-deletion-color-override: rgb(var(--git-deletion)) !important; --diffs-font-size: ${FONT.size}px !important; --diffs-font-family: ${FONT.mono} !important; } [data-change-icon] { width: 12px !important; height: 12px !important; } [data-diffs-header] { padding-inline: 12px !important; background: var(--card) !important; } `, }), [globalViewMode, resolvedTheme, enableComments, showHeader, handleLineSelectionEnd, wordWrap, onLineEnter, onLineLeave] ); const controlledSelection = showCommentForm ? selectedLines : null; if (!fileDiffMetadata) { return (
No diff available
); } return (
); }, arePropsEqual); /** Compact inline diff viewer for chat messages (Pierre implementation). */ export function DiffViewInline({ data, className, }: { data: FileDiffData; className?: string; }) { return ( ); }