"use client"; import { Check, ChevronDown, Copy, FileCode2, LoaderCircle, } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { type ReactNode, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { type AgentCodeLanguage, AgentCodeLine, useAgentCodeTokens, } from "@/components/agents/agent-code"; import { AgentDisclosure } from "@/components/agents/agent-disclosure"; import { SPRING_PRESS, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type FileDiffStatus = "streaming" | "complete"; export type FileDiffLineType = "added" | "removed" | "context"; export interface FileDiffLine { id: string; type?: FileDiffLineType; oldLine?: number; newLine?: number; content: string; } export interface FileDiffProps { file: ReactNode; lines: FileDiffLine[]; status?: FileDiffStatus; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; collapseOnComplete?: boolean; maxHeight?: number; language?: AgentCodeLanguage; copyText?: string; onCopy?: () => void | Promise; className?: string; } function ChangeCount({ value, type }: { value: number; type: "added" | "removed" }) { if (!value) return null; return ( {type === "added" ? "+" : "−"} {value} ); } export function FileDiff({ file, lines, status = "streaming", open, defaultOpen = true, onOpenChange, collapseOnComplete = true, maxHeight = 220, language = "typescript", copyText, onCopy, className, }: FileDiffProps) { const reduce = useReducedMotion() ?? false; const baseId = useId(); const triggerId = `${baseId}-trigger`; const contentId = `${baseId}-content`; const viewportRef = useRef(null); const previousStatus = useRef(status); const copyTimer = useRef(undefined); const [copied, setCopied] = useState(false); const [internalOpen, setInternalOpen] = useState(defaultOpen); const currentOpen = open ?? internalOpen; const streaming = status === "streaming"; const additions = lines.filter((line) => line.type === "added").length; const deletions = lines.filter((line) => line.type === "removed").length; const canCopy = Boolean(copyText || onCopy); const code = lines.map((line) => line.content).join("\n"); const tokens = useAgentCodeTokens(code, language); const setOpen = useCallback( (next: boolean) => { if (open === undefined) setInternalOpen(next); onOpenChange?.(next); }, [onOpenChange, open], ); useEffect(() => { if (previousStatus.current !== "streaming" && status === "streaming") { setOpen(true); } if ( previousStatus.current === "streaming" && status === "complete" && collapseOnComplete ) { setOpen(false); } previousStatus.current = status; }, [collapseOnComplete, setOpen, status]); useEffect( () => () => { if (copyTimer.current) window.clearTimeout(copyTimer.current); }, [], ); useLayoutEffect(() => { const viewport = viewportRef.current; if (!viewport || !currentOpen || !streaming) return; const frame = requestAnimationFrame(() => { if (viewport.scrollHeight <= viewport.clientHeight) return; if (typeof viewport.scrollTo === "function") { viewport.scrollTo({ top: viewport.scrollHeight, behavior: reduce ? "auto" : "smooth", }); } else { viewport.scrollTop = viewport.scrollHeight; } }); return () => cancelAnimationFrame(frame); }); const handleCopy = useCallback(async () => { if (onCopy) await onCopy(); else if (copyText) await navigator.clipboard?.writeText(copyText); setCopied(true); if (copyTimer.current) window.clearTimeout(copyTimer.current); copyTimer.current = window.setTimeout(() => setCopied(false), 1600); }, [copyText, onCopy]); return (
File changes {lines.map((line, index) => { const type = line.type ?? "context"; return (
{line.oldLine} {line.newLine} {type === "added" ? "+" : type === "removed" ? "−" : ""}
); })}
{canCopy ? (
{copied ? ( ) : ( )}
) : null}
); }