"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import dynamic from "next/dynamic" import type { OnMount } from "@monaco-editor/react" import type { editor as MonacoEditorNs, IRange } from "monaco-editor" import { ArrowLeft, ArrowRight, CheckCheck } from "lucide-react" import { useTranslations } from "next-intl" import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" import { useZoomLevel } from "@/hooks/use-appearance" import { cn } from "@/lib/utils" const EDITOR_BASE_FONT_SIZE = 13 import { Button } from "@/components/ui/button" import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable" import { computeLineDiff, computeMergeHunks, buildResult, type DiffHunk, type MergeHunk, } from "./merge-diff" import { useSyncScroll } from "./use-sync-scroll" import "@/lib/monaco-local" const MonacoEditor = dynamic( async () => { const mod = await import("@monaco-editor/react") return { default: mod.default } }, { ssr: false } ) interface ThreePaneMergeEditorProps { base: string ours: string theirs: string merged: string language?: string className?: string onContentChange?: (content: string) => void onConflictStatusChange?: (hasUnresolved: boolean) => void } export function ThreePaneMergeEditor({ base, ours, theirs, language = "plaintext", className, onContentChange, onConflictStatusChange, }: ThreePaneMergeEditorProps) { const t = useTranslations("MergePage") const editorTheme = useMonacoThemeSync() const { zoomLevel } = useZoomLevel() const { registerEditor } = useSyncScroll() const leftEditorRef = useRef( null ) const centerEditorRef = useRef( null ) const rightEditorRef = useRef( null ) // Decorations collections const leftDecorationsRef = useRef(null) const centerDecorationsRef = useRef(null) const rightDecorationsRef = useRef(null) // Scroll tick counter — incremented on every scroll to trigger gutter re-render const [scrollTick, setScrollTick] = useState(0) // Merge state const mergeHunks = useMemo( () => computeMergeHunks(base, ours, theirs), [base, ours, theirs] ) // Track which hunks have been applied and which side was chosen const [appliedHunks, setAppliedHunks] = useState< Map >(new Map()) // Track ignored hunks const [ignoredHunks, setIgnoredHunks] = useState>(new Set()) const onContentChangeRef = useRef(onContentChange) const onConflictStatusChangeRef = useRef(onConflictStatusChange) useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange]) useEffect(() => { onConflictStatusChangeRef.current = onConflictStatusChange }, [onConflictStatusChange]) // Compute diffs for left/right pane decorations const baseLines = useMemo(() => base.split("\n"), [base]) const leftDiffs = useMemo( () => computeLineDiff(baseLines, ours.split("\n")), [baseLines, ours] ) const rightDiffs = useMemo( () => computeLineDiff(baseLines, theirs.split("\n")), [baseLines, theirs] ) // Build the result content from base + applied hunks const resultContent = useMemo( () => buildResult(base, mergeHunks, appliedHunks), [base, mergeHunks, appliedHunks] ) // Notify parent of content changes useEffect(() => { onContentChangeRef.current?.(resultContent) }, [resultContent]) // Notify parent of conflict status useEffect(() => { const hasUnresolved = mergeHunks.some( (h) => h.type === "conflict" && !appliedHunks.has(h.id) && !ignoredHunks.has(h.id) ) onConflictStatusChangeRef.current?.(hasUnresolved) }, [mergeHunks, appliedHunks, ignoredHunks]) // Apply hunk handler const applyHunk = useCallback((id: string, side: "left" | "right") => { setAppliedHunks((prev) => { const next = new Map(prev) next.set(id, side) return next }) setIgnoredHunks((prev) => { const next = new Set(prev) next.delete(id) return next }) }, []) // Sync center editor content when result changes useEffect(() => { const editor = centerEditorRef.current if (!editor) return const currentValue = editor.getValue() if (currentValue !== resultContent) { const pos = editor.getPosition() editor.setValue(resultContent) if (pos) editor.setPosition(pos) } }, [resultContent]) // --------------------------------------------------------------------------- // Decorations for left (ours) pane // --------------------------------------------------------------------------- const applyLeftDecorations = useCallback( (editor: MonacoEditorNs.IStandaloneCodeEditor) => { const decorations: MonacoEditorNs.IModelDeltaDecoration[] = [] const oursLines = ours.split("\n") for (const hunk of leftDiffs) { const range = hunkToEditorRange(hunk, leftDiffs, oursLines.length) if (!range) continue const cssClass = hunk.baseCount === 0 ? "merge-hunk-added-bg" : hunk.newLines.length === 0 ? "merge-hunk-removed-bg" : "merge-hunk-modified-bg" decorations.push({ range, options: { isWholeLine: true, className: cssClass }, }) } if (leftDecorationsRef.current) { leftDecorationsRef.current.set(decorations) } else { leftDecorationsRef.current = editor.createDecorationsCollection(decorations) } }, [leftDiffs, ours] ) // --------------------------------------------------------------------------- // Decorations for right (theirs) pane // --------------------------------------------------------------------------- const applyRightDecorations = useCallback( (editor: MonacoEditorNs.IStandaloneCodeEditor) => { const decorations: MonacoEditorNs.IModelDeltaDecoration[] = [] const theirsLines = theirs.split("\n") for (const hunk of rightDiffs) { const range = hunkToEditorRange(hunk, rightDiffs, theirsLines.length) if (!range) continue const cssClass = hunk.baseCount === 0 ? "merge-hunk-added-bg" : hunk.newLines.length === 0 ? "merge-hunk-removed-bg" : "merge-hunk-modified-bg" decorations.push({ range, options: { isWholeLine: true, className: cssClass }, }) } if (rightDecorationsRef.current) { rightDecorationsRef.current.set(decorations) } else { rightDecorationsRef.current = editor.createDecorationsCollection(decorations) } }, [rightDiffs, theirs] ) // --------------------------------------------------------------------------- // Decorations for center (result) pane // --------------------------------------------------------------------------- const applyCenterDecorations = useCallback( (editor: MonacoEditorNs.IStandaloneCodeEditor) => { const decorations: MonacoEditorNs.IModelDeltaDecoration[] = [] const currentLines = resultContent.split("\n") let resultOffset = 0 const sortedHunks = [...mergeHunks].sort( (a, b) => a.baseStart - b.baseStart ) let lastBaseEnd = 0 for (const hunk of sortedHunks) { resultOffset += hunk.baseStart - lastBaseEnd const isApplied = appliedHunks.has(hunk.id) const isIgnored = ignoredHunks.has(hunk.id) let lineCount: number if (isApplied) { const side = appliedHunks.get(hunk.id)! const diffHunk = side === "left" ? hunk.leftHunk : hunk.rightHunk lineCount = diffHunk ? diffHunk.newLines.length : 0 } else { lineCount = hunk.baseCount } if (lineCount > 0) { const startLine = resultOffset + 1 const endLine = resultOffset + lineCount let cssClass: string if (isApplied) { cssClass = "merge-hunk-applied-bg" } else if (isIgnored) { cssClass = "" } else if (hunk.type === "conflict") { cssClass = "merge-hunk-conflict-bg" } else { cssClass = "merge-hunk-pending-bg" } if (cssClass) { decorations.push({ range: { startLineNumber: startLine, startColumn: 1, endLineNumber: Math.min(endLine, currentLines.length), endColumn: 1, }, options: { isWholeLine: true, className: cssClass }, }) } } resultOffset += lineCount lastBaseEnd = hunk.baseStart + hunk.baseCount } if (centerDecorationsRef.current) { centerDecorationsRef.current.set(decorations) } else { centerDecorationsRef.current = editor.createDecorationsCollection(decorations) } }, [mergeHunks, appliedHunks, ignoredHunks, resultContent] ) // --------------------------------------------------------------------------- // Apply decorations when state changes // --------------------------------------------------------------------------- useEffect(() => { if (leftEditorRef.current) { applyLeftDecorations(leftEditorRef.current) } }, [applyLeftDecorations]) useEffect(() => { if (centerEditorRef.current) { applyCenterDecorations(centerEditorRef.current) } }, [applyCenterDecorations]) useEffect(() => { if (rightEditorRef.current) { applyRightDecorations(rightEditorRef.current) } }, [applyRightDecorations]) // --------------------------------------------------------------------------- // Editor mount handlers // --------------------------------------------------------------------------- const handleLeftMount: OnMount = useCallback( (editor) => { leftEditorRef.current = editor registerEditor(editor, 0) applyLeftDecorations(editor) // Also listen to left editor scroll to update gutter editor.onDidScrollChange(() => { setScrollTick((n) => n + 1) }) // Trigger initial gutter render after editor is ready requestAnimationFrame(() => { setScrollTick((n) => n + 1) }) }, [registerEditor, applyLeftDecorations] ) const handleCenterMount: OnMount = useCallback( (editor) => { centerEditorRef.current = editor registerEditor(editor, 1) applyCenterDecorations(editor) editor.onDidChangeModelContent(() => { const value = editor.getValue() onContentChangeRef.current?.(value) }) }, [registerEditor, applyCenterDecorations] ) const handleRightMount: OnMount = useCallback( (editor) => { rightEditorRef.current = editor registerEditor(editor, 2) applyRightDecorations(editor) // Also listen to right editor scroll to update gutter editor.onDidScrollChange(() => { setScrollTick((n) => n + 1) }) // Trigger initial gutter render after editor is ready requestAnimationFrame(() => { setScrollTick((n) => n + 1) }) }, [registerEditor, applyRightDecorations] ) // --------------------------------------------------------------------------- // Compute gutter arrow items (line numbers only, positions computed at render) // --------------------------------------------------------------------------- const leftGutterItems = useMemo(() => { const oursLines = ours.split("\n") const items: Array<{ hunk: MergeHunk lineNumber: number }> = [] for (const hunk of mergeHunks) { if (!hunk.leftHunk) continue if (appliedHunks.has(hunk.id) || ignoredHunks.has(hunk.id)) continue const range = hunkToEditorRange( hunk.leftHunk, leftDiffs, oursLines.length ) if (!range) continue items.push({ hunk, lineNumber: range.startLineNumber }) } return items }, [mergeHunks, appliedHunks, ignoredHunks, leftDiffs, ours]) const rightGutterItems = useMemo(() => { const theirsLines = theirs.split("\n") const items: Array<{ hunk: MergeHunk lineNumber: number }> = [] for (const hunk of mergeHunks) { if (!hunk.rightHunk) continue if (appliedHunks.has(hunk.id) || ignoredHunks.has(hunk.id)) continue const range = hunkToEditorRange( hunk.rightHunk, rightDiffs, theirsLines.length ) if (!range) continue items.push({ hunk, lineNumber: range.startLineNumber }) } return items }, [mergeHunks, appliedHunks, ignoredHunks, rightDiffs, theirs]) // --------------------------------------------------------------------------- // Toolbar actions // --------------------------------------------------------------------------- const handleApplyAllNonConflicting = useCallback(() => { setAppliedHunks((prev) => { const next = new Map(prev) for (const hunk of mergeHunks) { if (hunk.type === "left-only" && hunk.leftHunk && !next.has(hunk.id)) { next.set(hunk.id, "left") } else if ( hunk.type === "right-only" && hunk.rightHunk && !next.has(hunk.id) ) { next.set(hunk.id, "right") } } return next }) }, [mergeHunks]) const handleApplyLeftNonConflicting = useCallback(() => { setAppliedHunks((prev) => { const next = new Map(prev) for (const hunk of mergeHunks) { if (hunk.type === "left-only" && hunk.leftHunk && !next.has(hunk.id)) { next.set(hunk.id, "left") } } return next }) }, [mergeHunks]) const handleApplyRightNonConflicting = useCallback(() => { setAppliedHunks((prev) => { const next = new Map(prev) for (const hunk of mergeHunks) { if ( hunk.type === "right-only" && hunk.rightHunk && !next.has(hunk.id) ) { next.set(hunk.id, "right") } } return next }) }, [mergeHunks]) // --------------------------------------------------------------------------- // Statistics // --------------------------------------------------------------------------- const unresolvedConflicts = mergeHunks.filter( (h) => h.type === "conflict" && !appliedHunks.has(h.id) && !ignoredHunks.has(h.id) ).length const pendingNonConflicts = mergeHunks.filter( (h) => h.type !== "conflict" && !appliedHunks.has(h.id) && !ignoredHunks.has(h.id) ).length const totalChanges = mergeHunks.length // --------------------------------------------------------------------------- // Editor options // --------------------------------------------------------------------------- const editorOptions = useMemo( () => ({ fontSize: (EDITOR_BASE_FONT_SIZE * zoomLevel) / 100, minimap: { enabled: false }, scrollBeyondLastLine: false, automaticLayout: true, lineNumbers: "on", glyphMargin: true, folding: false, wordWrap: "off", overviewRulerLanes: 0, }), [zoomLevel] ) const readonlyOptions = useMemo( () => ({ ...editorOptions, readOnly: true, domReadOnly: true, }), [editorOptions] ) const loadingEl = (
Loading editor...
) return (
{/* Header */}
{t("localVersion")}
{t("result")} {unresolvedConflicts > 0 && ( ({unresolvedConflicts}{" "} {unresolvedConflicts === 1 ? "conflict" : "conflicts"}) )} {pendingNonConflicts > 0 && ( ({pendingNonConflicts} pending) )} {totalChanges > 0 && unresolvedConflicts === 0 && pendingNonConflicts === 0 && ( )}
{pendingNonConflicts > 0 && (
)}
{t("remoteVersion")}
{/* Three-panel layout: [left editor + gutter] | center editor | [gutter + right editor] */} {/* Left: Ours (local) + arrow gutter */}
applyHunk(id, "left")} title={t("acceptLocal")} />
{/* Center: Result (editable) */} {/* Right: arrow gutter + Theirs (remote) */}
applyHunk(id, "right")} title={t("acceptRemote")} />
) } // --------------------------------------------------------------------------- // Arrow Gutter Component // --------------------------------------------------------------------------- interface ArrowGutterProps { items: Array<{ hunk: MergeHunk; lineNumber: number }> direction: "left" | "right" editorRef: React.RefObject scrollTick: number // triggers re-render on scroll onApply: (hunkId: string) => void title: string } function ArrowGutter({ items, direction, editorRef, scrollTick, onApply, title, }: ArrowGutterProps) { const editor = editorRef.current const positioned = useMemo(() => { if (!editor) return [] return items .map(({ hunk, lineNumber }) => { const pos = editor.getScrolledVisiblePosition({ lineNumber, column: 1, }) return pos ? { hunk, top: pos.top } : null }) .filter((item): item is { hunk: MergeHunk; top: number } => item !== null) // scrollTick is included to recompute on scroll // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor, items, scrollTick]) return (
{positioned.map(({ hunk, top }) => ( ))}
) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Convert a DiffHunk to an editor range in the modified file, * accounting for offset from previous hunks. */ function hunkToEditorRange( hunk: DiffHunk, allHunks: DiffHunk[], totalLines: number ): IRange | null { let offset = 0 for (const h of allHunks) { if (h.baseStart >= hunk.baseStart) break offset += h.newLines.length - h.baseCount } if (hunk.newLines.length > 0) { const start = hunk.baseStart + offset + 1 const end = start + hunk.newLines.length - 1 return { startLineNumber: start, startColumn: 1, endLineNumber: Math.min(end, totalLines), endColumn: 1, } } else if (hunk.baseCount > 0) { const line = Math.min(hunk.baseStart + offset + 1, totalLines) return { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1, } } return null }