import { useEffect, useMemo, useState } from "react"; import { AlertCircle, CheckCircle2, ChevronDown, ChevronRight, ChevronUp, CircleDashed, ExternalLink, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { FileReferenceChip } from "@/components/FileReferenceChip"; import { hasRenderableFileDiff, parseRenderableFileDiff, type RenderableFileDiff, type RenderableFileDiffHunk, type RenderableFileDiffLine, } from "@/lib/file-diff"; import type { FileEditDisplayMode } from "@/lib/local-preferences"; import type { UIFileDiff, UIFileEdit } from "@/lib/types"; import { cn } from "@/lib/utils"; import { ActivityStep } from "./ActivityStep"; import { DiffPair } from "./DiffPair"; const INITIAL_VISIBLE_DIFF_LINES = 160; const AUTO_COLLAPSE_DIFF_LINES = INITIAL_VISIBLE_DIFF_LINES; type DiffFileEditDisplayMode = Exclude; interface VisibleDiffHunk { hunk: RenderableFileDiffHunk; skippedBefore: number; } interface VisibleDiff { hunks: VisibleDiffHunk[]; hiddenLineCount: number; } const EMPTY_VISIBLE_DIFF: VisibleDiff = { hunks: [], hiddenLineCount: 0 }; export interface FileEditSummary { key: string; path: string; absolute_path?: string; added: number; deleted: number; approximate: boolean; binary: boolean; status: UIFileEdit["status"]; operation?: UIFileEdit["operation"]; pending: boolean; error?: string; diff?: UIFileDiff; } export function FileEditGroup({ edits, displayMode, onOpenFilePreview, density = "default", }: { edits: FileEditSummary[]; displayMode: FileEditDisplayMode; onOpenFilePreview?: (path: string) => void; density?: "default" | "diff-only"; }) { if (edits.length === 0) return null; return ( ); } function canRenderDiff( edit: FileEditSummary, displayMode: FileEditDisplayMode, ): displayMode is DiffFileEditDisplayMode { return ( displayMode !== "summary" && edit.status !== "editing" && edit.status !== "error" && hasRenderableFileDiff(edit.diff) ); } function FileEditDiffOnly({ edit, displayMode, onOpenFilePreview, }: { edit: FileEditSummary; displayMode: DiffFileEditDisplayMode; onOpenFilePreview?: (path: string) => void; }) { return (
  • ); } function FileEditRow({ edit, displayMode, onOpenFilePreview, }: { edit: FileEditSummary; displayMode: FileEditDisplayMode; onOpenFilePreview?: (path: string) => void; }) { const { t } = useTranslation(); const editing = edit.status === "editing"; const failed = edit.status === "error"; const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit); const showDiff = canRenderDiff(edit, displayMode); const rawFailureDetail = failed ? cleanFileEditError(edit.error) : ""; const failureDetail = failed ? formatFileEditError(edit.error) || t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." }) : ""; const statusIcon = failed ? ( ) : editing ? ( ) : ( ); return ( {statusIcon} )} active={editing} tone={failed ? "error" : editing ? "active" : "success"} className="text-xs" contentClassName={failed || showDiff ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"} title={rawFailureDetail || edit.absolute_path || edit.path} label={edit.pending && !edit.path ? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" }) : ( )} detail={null} aside={hasCountedDiff ? : null} > {failed ? ( {failureDetail} ) : null} {showDiff ? ( ) : null} ); } export function hasVisibleDiffStats(edit: Pick): boolean { return edit.added > 0 || edit.deleted > 0; } function cleanFileEditError(error?: string): string { const firstLine = (error || "").replace(/\s+/g, " ").trim(); if (!firstLine) return ""; return firstLine .replace(/^Error applying patch:\s*/i, "") .replace(/^Error writing file:\s*/i, "") .replace(/^Error editing file:\s*/i, "") .replace(/^Error:\s*/i, ""); } function formatFileEditError(error?: string): string { const cleaned = cleanFileEditError(error); if (!cleaned) return ""; if (/\bpermission denied\b/i.test(cleaned) || /\boperation not permitted\b/i.test(cleaned)) { return "No permission to change this location."; } return cleaned .replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.") .replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.") .replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.") .replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.") .slice(0, 180); } function FileUnifiedDiff({ diff, collapsed, added, deleted, showCollapsedStats = true, previewPath, onOpenFilePreview, }: { diff: UIFileDiff; collapsed: boolean; added: number; deleted: number; showCollapsedStats?: boolean; previewPath?: string; onOpenFilePreview?: (path: string) => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const [open, setOpen] = useState(false); const [expandedLines, setExpandedLines] = useState(false); const renderableDiff = useMemo(() => parseRenderableFileDiff(diff), [diff]); const totalLineCount = useMemo(() => countDiffLines(renderableDiff), [renderableDiff]); const shouldAutoCollapse = totalLineCount > AUTO_COLLAPSE_DIFF_LINES || !!diff.truncated; const startsCollapsed = collapsed || shouldAutoCollapse; const shouldRenderBody = !startsCollapsed || open; const shouldLimitLines = totalLineCount > INITIAL_VISIBLE_DIFF_LINES; const lineLimit = expandedLines || !shouldLimitLines ? totalLineCount : INITIAL_VISIBLE_DIFF_LINES; const visibleDiff = useMemo( () => shouldRenderBody ? selectVisibleDiffLines(renderableDiff, lineLimit, totalLineCount) : EMPTY_VISIBLE_DIFF, [lineLimit, renderableDiff, shouldRenderBody, totalLineCount], ); const lineCountLabel = t("message.fileEditDiffLineCount", { count: diff.truncated ? `${totalLineCount}+` : totalLineCount, defaultValue: "{{count}} lines", }); const viewDiffLabel = shouldAutoCollapse ? tx("message.fileEditViewLargeDiff", "View large diff") : tx("message.fileEditViewDiff", "View diff"); useEffect(() => { setOpen(false); setExpandedLines(false); }, [diff]); const handleToggleOpen = () => { if (open) setExpandedLines(false); setOpen(!open); }; if (totalLineCount === 0) return null; const renderBody = () => (
    {visibleDiff.hunks.map(({ hunk, skippedBefore }, index) => (
    0 && "border-t border-border/45")} > {skippedBefore > 0 ? : null}
    {hunk.lines.map((line, lineIndex) => ( ))}
    ))} {visibleDiff.hiddenLineCount > 0 ? (
    ) : expandedLines && shouldLimitLines ? (
    ) : null} {diff.truncated ? (
    {tx("message.fileEditDiffTruncated", "Diff truncated. Open the file for the full change.")} {previewPath && onOpenFilePreview ? ( ) : null}
    ) : null}
    ); if (!startsCollapsed) return renderBody(); return (
    {open ? renderBody() : null}
    ); } function countDiffLines(diff: RenderableFileDiff): number { return diff.hunks.reduce((total, hunk) => total + hunk.lines.length, 0); } function selectVisibleDiffLines( diff: RenderableFileDiff, lineLimit: number, totalLineCount: number, ): VisibleDiff { if (lineLimit >= totalLineCount) { return { hunks: diff.hunks.map((hunk, index) => ({ hunk, skippedBefore: index > 0 ? countSkippedUnchangedLines(diff.hunks[index - 1], hunk) : 0, })), hiddenLineCount: 0, }; } let remaining = Math.max(0, lineLimit); const hunks: VisibleDiffHunk[] = []; let previousHunk: RenderableFileDiffHunk | null = null; for (const hunk of diff.hunks) { if (remaining <= 0) break; const skippedBefore = previousHunk ? countSkippedUnchangedLines(previousHunk, hunk) : 0; if (hunk.lines.length <= remaining) { hunks.push({ hunk, skippedBefore }); remaining -= hunk.lines.length; previousHunk = hunk; continue; } hunks.push({ hunk: { ...hunk, lines: hunk.lines.slice(0, remaining) }, skippedBefore }); remaining = 0; previousHunk = hunk; } return { hunks, hiddenLineCount: Math.max(0, totalLineCount - lineLimit), }; } function countSkippedUnchangedLines( previous: RenderableFileDiffHunk, current: RenderableFileDiffHunk, ): number { const oldGap = current.old_start - (previous.old_start + previous.old_lines); const newGap = current.new_start - (previous.new_start + previous.new_lines); return Math.max(0, oldGap, newGap); } function DiffHunkGap({ lineCount }: { lineCount: number }) { const { t } = useTranslation(); return (
    ... {t("message.fileEditUnchangedLinesHidden", { count: lineCount, defaultValue: "{{count}} unchanged lines hidden", })}
    ); } function DiffLineRow({ line }: { line: RenderableFileDiffLine }) { const kind = line.kind === "add" || line.kind === "delete" ? line.kind : "context"; const marker = kind === "add" ? "+" : kind === "delete" ? "-" : " "; return ( {line.old_lineno ?? ""} {line.new_lineno ?? ""} {marker} {line.content || " "} ); }