"use client" import { useCallback, useEffect, useMemo, useRef } from "react" import dynamic from "next/dynamic" import type { editor as MonacoEditorNs } from "monaco-editor" import { useTranslations } from "next-intl" import { useFolderContext } from "@/contexts/folder-context" import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" import { cn } from "@/lib/utils" import "@/lib/monaco-local" type RowMarker = "none" | "added" | "deleted" | "modified" type DiffFileMode = "modified" | "added" | "deleted" | "renamed" interface RawDiffRow { kind: "context" | "add" | "del" text: string oldLine: number | null newLine: number | null } interface ParsedDiffRow { type: "context" | "added" | "deleted" | "modified" text: string sign: " " | "+" | "-" oldLine: number | null newLine: number | null } interface ParsedDiffHunk { key: string oldStart: number | null oldCount: number | null newStart: number | null newCount: number | null rows: ParsedDiffRow[] } interface ParsedDiffFile { key: string path: string oldPath: string | null newPath: string | null mode: DiffFileMode additions: number deletions: number hunks: ParsedDiffHunk[] } interface WorkingHunk { key: string oldStart: number | null oldCount: number | null newStart: number | null newCount: number | null rows: RawDiffRow[] } interface WorkingFile { key: string path: string oldPath: string | null newPath: string | null mode: DiffFileMode additions: number deletions: number hunks: WorkingHunk[] } interface HunkPreviewLine { text: string marker: RowMarker } const MonacoEditor = dynamic(async () => import("@monaco-editor/react"), { ssr: false, }) function normalizePath(raw: string): string | null { const trimmed = raw.trim().replace(/^"|"$/g, "") if (!trimmed || trimmed === "/dev/null") return null if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) { return trimmed.slice(2).replace(/\\/g, "/") } return trimmed.replace(/\\/g, "/") } function parsePathFromDiffGitLine(line: string): string | null { const match = line.match(/^diff --git\s+(.+?)\s+(.+)$/) if (!match) return null return normalizePath(match[2]) ?? normalizePath(match[1]) } function parseApplyPatchMarker(line: string): { path: string | null mode: DiffFileMode } | null { if (line.startsWith("*** Update File: ")) { return { path: normalizePath(line.slice("*** Update File: ".length)), mode: "modified", } } if (line.startsWith("*** Add File: ")) { return { path: normalizePath(line.slice("*** Add File: ".length)), mode: "added", } } if (line.startsWith("*** Delete File: ")) { return { path: normalizePath(line.slice("*** Delete File: ".length)), mode: "deleted", } } return null } function parseHunkHeader(line: string): { oldStart: number | null oldCount: number | null newStart: number | null newCount: number | null } | null { if (!line.startsWith("@@")) return null const match = line.match(/^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@/) if (!match) { return { oldStart: null, oldCount: null, newStart: null, newCount: null, } } return { oldStart: Number(match[1]), oldCount: match[2] ? Number(match[2]) : 1, newStart: Number(match[3]), newCount: match[4] ? Number(match[4]) : 1, } } function classifyRows(rows: RawDiffRow[]): ParsedDiffRow[] { const parsed: ParsedDiffRow[] = [] let index = 0 while (index < rows.length) { const current = rows[index] if (!current) break if (current.kind === "context") { parsed.push({ type: "context", text: current.text, sign: " ", oldLine: current.oldLine, newLine: current.newLine, }) index += 1 continue } if (current.kind === "add") { let addEnd = index while (addEnd < rows.length && rows[addEnd]?.kind === "add") { const row = rows[addEnd] if (!row) break parsed.push({ type: "added", text: row.text, sign: "+", oldLine: row.oldLine, newLine: row.newLine, }) addEnd += 1 } index = addEnd continue } let delEnd = index while (delEnd < rows.length && rows[delEnd]?.kind === "del") { delEnd += 1 } let addEnd = delEnd while (addEnd < rows.length && rows[addEnd]?.kind === "add") { addEnd += 1 } const delRows = rows.slice(index, delEnd) const addRows = rows.slice(delEnd, addEnd) const modifiedPairs = Math.min(delRows.length, addRows.length) for (const [delta, row] of delRows.entries()) { parsed.push({ type: delta < modifiedPairs ? "modified" : "deleted", text: row.text, sign: "-", oldLine: row.oldLine, newLine: row.newLine, }) } for (const [delta, row] of addRows.entries()) { parsed.push({ type: delta < modifiedPairs ? "modified" : "added", text: row.text, sign: "+", oldLine: row.oldLine, newLine: row.newLine, }) } index = addEnd } return parsed } function resolveFileMode(file: WorkingFile): DiffFileMode { if (file.mode !== "modified") return file.mode if (file.oldPath && !file.newPath) return "deleted" if (!file.oldPath && file.newPath) return "added" if (file.oldPath && file.newPath && file.oldPath !== file.newPath) { return "renamed" } return "modified" } function parseUnifiedDiff(diffText: string): ParsedDiffFile[] { const lines = diffText.replace(/\r\n/g, "\n").split("\n") const files: WorkingFile[] = [] let fileIndex = 1 let hunkIndex = 1 let currentFile: WorkingFile | null = null let currentHunk: WorkingHunk | null = null let oldLineCursor: number | null = null let newLineCursor: number | null = null let inferredOldCursorForNextHunk = 1 let inferredNewCursorForNextHunk = 1 const getActiveFile = (): WorkingFile | null => currentFile ?? files[files.length - 1] ?? null const getActiveHunk = (): WorkingHunk | null => currentHunk const getOldLineCursor = (): number | null => oldLineCursor const getNewLineCursor = (): number | null => newLineCursor const flushHunk = () => { const file = getActiveFile() if (!file || !currentHunk) return file.hunks.push(currentHunk) if (oldLineCursor !== null) { inferredOldCursorForNextHunk = Math.max(1, oldLineCursor) } if (newLineCursor !== null) { inferredNewCursorForNextHunk = Math.max(1, newLineCursor) } currentHunk = null } const startFile = ( path: string | null, mode: DiffFileMode = "modified" ): WorkingFile => { flushHunk() currentFile = { key: `file-${fileIndex}`, path: path ?? `Diff #${fileIndex}`, oldPath: null, newPath: null, mode, additions: 0, deletions: 0, hunks: [], } files.push(currentFile) fileIndex += 1 inferredOldCursorForNextHunk = 1 inferredNewCursorForNextHunk = 1 return currentFile } const ensureFile = () => getActiveFile() ?? startFile(null) const startHunk = (line: string) => { const file = ensureFile() flushHunk() const parsed = parseHunkHeader(line) const resolvedOldStart = parsed?.oldStart ?? inferredOldCursorForNextHunk const resolvedNewStart = parsed?.newStart ?? inferredNewCursorForNextHunk oldLineCursor = resolvedOldStart newLineCursor = resolvedNewStart currentHunk = { key: `${file.key}:hunk-${hunkIndex}`, oldStart: resolvedOldStart, oldCount: parsed?.oldCount ?? null, newStart: resolvedNewStart, newCount: parsed?.newCount ?? null, rows: [], } hunkIndex += 1 } for (const line of lines) { if (line.startsWith("diff --git ")) { startFile(parsePathFromDiffGitLine(line)) continue } const applyPatchMarker = parseApplyPatchMarker(line) if (applyPatchMarker) { startFile(applyPatchMarker.path, applyPatchMarker.mode) continue } if (line.startsWith("*** Move to: ")) { const movedPath = normalizePath(line.slice("*** Move to: ".length)) const file = getActiveFile() if (file && movedPath) { file.newPath = movedPath file.path = movedPath file.mode = "renamed" } continue } if (line.startsWith("--- ")) { const file = ensureFile() const oldPath = normalizePath(line.slice(4)) file.oldPath = oldPath if (!file.newPath && oldPath) file.path = oldPath continue } if (line.startsWith("+++ ")) { const file = ensureFile() const newPath = normalizePath(line.slice(4)) file.newPath = newPath if (newPath) file.path = newPath continue } if (line.startsWith("@@")) { startHunk(line) continue } const hunk = getActiveHunk() if (!hunk) continue if (line.startsWith("+") && !line.startsWith("+++")) { hunk.rows.push({ kind: "add", text: line.slice(1), oldLine: null, newLine: newLineCursor, }) const cursor = getNewLineCursor() if (cursor !== null) newLineCursor = cursor + 1 const file = getActiveFile() if (file) file.additions += 1 continue } if (line.startsWith("-") && !line.startsWith("---")) { hunk.rows.push({ kind: "del", text: line.slice(1), oldLine: oldLineCursor, newLine: null, }) const cursor = getOldLineCursor() if (cursor !== null) oldLineCursor = cursor + 1 const file = getActiveFile() if (file) file.deletions += 1 continue } if (line.startsWith(" ")) { hunk.rows.push({ kind: "context", text: line.slice(1), oldLine: oldLineCursor, newLine: newLineCursor, }) const nextOldCursor = getOldLineCursor() if (nextOldCursor !== null) oldLineCursor = nextOldCursor + 1 const nextNewCursor = getNewLineCursor() if (nextNewCursor !== null) newLineCursor = nextNewCursor + 1 } } flushHunk() return files .map((file) => ({ ...file, mode: resolveFileMode(file), hunks: file.hunks .filter((hunk) => hunk.rows.length > 0) .map((hunk) => ({ key: hunk.key, oldStart: hunk.oldStart, oldCount: hunk.oldCount, newStart: hunk.newStart, newCount: hunk.newCount, rows: classifyRows(hunk.rows), })), })) .filter((file) => file.hunks.length > 0) } function modeKey( mode: DiffFileMode ): "mode.added" | "mode.deleted" | "mode.renamed" | "mode.modified" { if (mode === "added") return "mode.added" if (mode === "deleted") return "mode.deleted" if (mode === "renamed") return "mode.renamed" return "mode.modified" } function toDisplayPath(filePath: string, folderPath: string | null): string { const normalizedPath = filePath.replace(/\\/g, "/") if (!folderPath) return normalizedPath const normalizedFolder = folderPath.replace(/\\/g, "/").replace(/\/+$/, "") if (!normalizedFolder) return normalizedPath const prefix = `${normalizedFolder}/` if (normalizedPath.startsWith(prefix)) { return normalizedPath.slice(prefix.length) } return normalizedPath } function countHunkChanges(hunk: ParsedDiffHunk): { additions: number deletions: number } { let additions = 0 let deletions = 0 for (const row of hunk.rows) { if (row.sign === "+") additions += 1 if (row.sign === "-") deletions += 1 } return { additions, deletions } } function buildHunkPreviewLines(rows: ParsedDiffRow[]): { lines: HunkPreviewLine[] } { const lines: HunkPreviewLine[] = rows.map((row) => { let marker: RowMarker = "none" if (row.type === "added") marker = "added" else if (row.type === "deleted") marker = "deleted" else if (row.type === "modified") marker = "modified" return { text: `${row.sign}${row.text}`, marker, } }) return { lines, } } function HunkMonacoPreview({ hunk, modelId, theme, }: { hunk: ParsedDiffHunk modelId: string theme: string }) { const t = useTranslations("Folder.diffPreview") const editorRef = useRef(null) const decorationsRef = useRef([]) const { lines } = useMemo(() => buildHunkPreviewLines(hunk.rows), [hunk.rows]) const renderedContent = useMemo( () => lines.map((line) => line.text).join("\n"), [lines] ) const applyDecorations = useCallback(() => { const editor = editorRef.current if (!editor) return const model = editor.getModel() if (!model) return const maxLine = model.getLineCount() const decorations: MonacoEditorNs.IModelDeltaDecoration[] = [] for (const [index, line] of lines.entries()) { const lineNumber = index + 1 if (lineNumber > maxLine) continue let cls: string | null = null if (line.marker === "added") { cls = "codeg-session-diff-line-added" } else if (line.marker === "modified") { cls = "codeg-session-diff-line-modified" } else if (line.marker === "deleted") { cls = "codeg-session-diff-line-deleted" } if (!cls) continue decorations.push({ range: { startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: 1, }, options: { isWholeLine: true, className: cls, }, }) } decorationsRef.current = editor.deltaDecorations( decorationsRef.current, decorations ) }, [lines]) useEffect(() => { applyDecorations() }, [applyDecorations]) useEffect( () => () => { const editor = editorRef.current if (!editor) return editor.deltaDecorations(decorationsRef.current, []) decorationsRef.current = [] }, [] ) return ( { editorRef.current = editor applyDecorations() }} path={`inmemory://session-hunk/${encodeURIComponent(modelId)}`} value={renderedContent} language="plaintext" theme={theme} loading={
{t("loadingHunk")}
} options={{ readOnly: true, minimap: { enabled: false }, automaticLayout: true, fontSize: 12, lineNumbers: "off", lineDecorationsWidth: 10, glyphMargin: false, wordWrap: "off", scrollBeyondLastLine: false, renderLineHighlight: "none", contextmenu: false, folding: false, scrollbar: { alwaysConsumeMouseWheel: false, }, padding: { top: 6, bottom: 6 }, }} /> ) } export function UnifiedDiffPreview({ diffText, modelId, className, }: { diffText: string modelId?: string className?: string }) { const t = useTranslations("Folder.diffPreview") const { folder } = useFolderContext() const files = useMemo(() => parseUnifiedDiff(diffText), [diffText]) const theme = useMonacoThemeSync() if (!diffText.trim()) { return (
{t("noDiffData")}
) } if (files.length === 0) { return (
          {diffText}
        
) } return (
{files.map((file) => (
{t(modeKey(file.mode))} {toDisplayPath(file.path, folder?.path ?? null)} +{file.additions} -{file.deletions}
{file.hunks.map((hunk, index) => { const hunkStats = countHunkChanges(hunk) return (
{t("hunkLabel", { index: index + 1 })} +{hunkStats.additions} -{hunkStats.deletions}
) })}
))}
) }