支持git冲突时弹出窗口合并代码解决冲突

This commit is contained in:
xintaofei
2026-03-14 20:55:15 +08:00
parent f503c25161
commit 4129f02985
25 changed files with 3123 additions and 51 deletions

View File

@@ -0,0 +1,102 @@
export interface ConflictRegion {
/** Line number (1-based) of <<<<<<< marker */
startLine: number
/** Line number (1-based) of ======= marker */
separatorLine: number
/** Line number (1-based) of >>>>>>> marker */
endLine: number
/** Content from the ours (local/HEAD) side */
oursContent: string
/** Content from the theirs (remote/incoming) side */
theirsContent: string
}
/**
* Parse git conflict markers from file content.
* Returns an array of conflict regions sorted by line number.
*/
export function parseConflictMarkers(content: string): ConflictRegion[] {
const lines = content.split("\n")
const regions: ConflictRegion[] = []
let i = 0
while (i < lines.length) {
if (lines[i].startsWith("<<<<<<<")) {
const startLine = i + 1 // 1-based
let separatorLine = -1
let endLine = -1
const oursLines: string[] = []
const theirsLines: string[] = []
let inOurs = true
let j = i + 1
while (j < lines.length) {
if (lines[j].startsWith("=======") && separatorLine === -1) {
separatorLine = j + 1
inOurs = false
} else if (lines[j].startsWith(">>>>>>>")) {
endLine = j + 1
break
} else if (inOurs) {
oursLines.push(lines[j])
} else {
theirsLines.push(lines[j])
}
j++
}
if (separatorLine !== -1 && endLine !== -1) {
regions.push({
startLine,
separatorLine,
endLine,
oursContent: oursLines.join("\n"),
theirsContent: theirsLines.join("\n"),
})
i = j + 1
continue
}
}
i++
}
return regions
}
/**
* Resolve a single conflict region by replacing the conflict block
* with the chosen content.
*/
export function resolveConflict(
content: string,
region: ConflictRegion,
choice: "ours" | "theirs" | "both"
): string {
const lines = content.split("\n")
const startIdx = region.startLine - 1
const endIdx = region.endLine - 1
let replacement: string
switch (choice) {
case "ours":
replacement = region.oursContent
break
case "theirs":
replacement = region.theirsContent
break
case "both":
replacement = region.oursContent + "\n" + region.theirsContent
break
}
const replacementLines = replacement === "" ? [] : replacement.split("\n")
lines.splice(startIdx, endIdx - startIdx + 1, ...replacementLines)
return lines.join("\n")
}
/**
* Check if content still has unresolved conflict markers.
*/
export function hasConflictMarkers(content: string): boolean {
return content.includes("<<<<<<<") && content.includes(">>>>>>>")
}

View File

@@ -0,0 +1,355 @@
/**
* Line-level diff engine for three-way merge.
*
* Computes diffs between base↔ours and base↔theirs, then aligns
* them into MergeHunks classified as left-only, right-only, or conflict.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface DiffHunk {
/** Start index in the "old" (base) array, 0-based */
baseStart: number
/** Number of lines removed from base (0 = pure insertion) */
baseCount: number
/** Replacement lines from the "new" side */
newLines: string[]
}
export type HunkStatus = "pending" | "applied" | "ignored"
export interface MergeHunk {
id: string
/** Start index in base lines, 0-based */
baseStart: number
/** Number of base lines covered */
baseCount: number
/** Diff hunk from ours (left) side, null if unchanged */
leftHunk: DiffHunk | null
/** Diff hunk from theirs (right) side, null if unchanged */
rightHunk: DiffHunk | null
type: "left-only" | "right-only" | "conflict"
}
// ---------------------------------------------------------------------------
// LCS-based line diff
// ---------------------------------------------------------------------------
/**
* Compute the Longest Common Subsequence table for two string arrays.
* Returns a 2D array where dp[i][j] = LCS length for a[0..i-1], b[0..j-1].
*/
function lcsTable(a: string[], b: string[]): number[][] {
const m = a.length
const n = b.length
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0)
)
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
}
return dp
}
/**
* Backtrack the LCS table to produce edit operations.
* Returns an array of { type, aIdx, bIdx } entries.
*/
interface EditOp {
type: "equal" | "delete" | "insert"
aIdx: number // index in a (-1 for insert)
bIdx: number // index in b (-1 for delete)
}
function backtrackLCS(a: string[], b: string[], dp: number[][]): EditOp[] {
const ops: EditOp[] = []
let i = a.length
let j = b.length
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
ops.push({ type: "equal", aIdx: i - 1, bIdx: j - 1 })
i--
j--
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
ops.push({ type: "insert", aIdx: -1, bIdx: j - 1 })
j--
} else {
ops.push({ type: "delete", aIdx: i - 1, bIdx: -1 })
i--
}
}
return ops.reverse()
}
/**
* Compute line-level diff hunks between old (a) and new (b) arrays.
*/
export function computeLineDiff(a: string[], b: string[]): DiffHunk[] {
const dp = lcsTable(a, b)
const ops = backtrackLCS(a, b, dp)
const hunks: DiffHunk[] = []
let idx = 0
while (idx < ops.length) {
const op = ops[idx]
if (op.type === "equal") {
idx++
continue
}
// Start of a change region
let baseStart = op.type === "delete" ? op.aIdx : -1
let baseCount = 0
const newLines: string[] = []
while (idx < ops.length && ops[idx].type !== "equal") {
const cur = ops[idx]
if (cur.type === "delete") {
if (baseStart === -1) baseStart = cur.aIdx
baseCount++
} else {
// insert
if (baseStart === -1) {
// Pure insertion — position it at the next base line
// Find the previous equal op's aIdx + 1, or 0
baseStart = findInsertionPoint(ops, idx)
}
newLines.push(b[cur.bIdx])
}
idx++
}
hunks.push({ baseStart, baseCount, newLines })
}
return hunks
}
/**
* For a pure insertion (no deletes in this hunk), determine
* where in the base array to anchor it.
*/
function findInsertionPoint(ops: EditOp[], currentIdx: number): number {
// Walk backwards to find the last "equal" or "delete" op
for (let k = currentIdx - 1; k >= 0; k--) {
if (ops[k].type === "equal" || ops[k].type === "delete") {
return ops[k].aIdx + 1
}
}
// If nothing found, insert at start
return 0
}
// ---------------------------------------------------------------------------
// Three-way merge hunk computation
// ---------------------------------------------------------------------------
interface RangedHunk {
baseStart: number
baseEnd: number // exclusive
hunk: DiffHunk
side: "left" | "right"
}
/**
* Given diff hunks from base→ours and base→theirs, produce
* a list of MergeHunks sorted by base position.
*/
export function computeMergeHunks(
base: string,
ours: string,
theirs: string
): MergeHunk[] {
const baseLines = base.split("\n")
const oursLines = ours.split("\n")
const theirsLines = theirs.split("\n")
const leftDiffs = computeLineDiff(baseLines, oursLines)
const rightDiffs = computeLineDiff(baseLines, theirsLines)
// Convert to ranged hunks for overlap detection
const ranged: RangedHunk[] = []
for (const h of leftDiffs) {
ranged.push({
baseStart: h.baseStart,
baseEnd: h.baseStart + Math.max(h.baseCount, 1), // at least 1 for insertions
hunk: h,
side: "left",
})
}
for (const h of rightDiffs) {
ranged.push({
baseStart: h.baseStart,
baseEnd: h.baseStart + Math.max(h.baseCount, 1),
hunk: h,
side: "right",
})
}
// Sort by baseStart, then by side (left first)
ranged.sort(
(a, b) => a.baseStart - b.baseStart || (a.side === "left" ? -1 : 1)
)
// Merge overlapping hunks from different sides into conflicts
const mergeHunks: MergeHunk[] = []
const used = new Set<number>()
for (let i = 0; i < ranged.length; i++) {
if (used.has(i)) continue
const r = ranged[i]
// Check for overlapping hunk from the other side
let paired: RangedHunk | null = null
let pairedIdx = -1
for (let j = i + 1; j < ranged.length; j++) {
if (used.has(j)) continue
const s = ranged[j]
if (s.side === r.side) continue
// Check overlap: ranges [r.baseStart, r.baseEnd) and [s.baseStart, s.baseEnd)
if (s.baseStart < r.baseEnd && r.baseStart < s.baseEnd) {
paired = s
pairedIdx = j
break
}
// If s starts beyond r, no more overlaps possible
if (s.baseStart >= r.baseEnd) break
}
if (paired && pairedIdx >= 0) {
used.add(pairedIdx)
// Check if both sides made identical changes — treat as non-conflict
const leftH = r.side === "left" ? r.hunk : paired.hunk
const rightH = r.side === "right" ? r.hunk : paired.hunk
const identical =
leftH.baseStart === rightH.baseStart &&
leftH.baseCount === rightH.baseCount &&
leftH.newLines.length === rightH.newLines.length &&
leftH.newLines.every((line, k) => line === rightH.newLines[k])
if (identical) {
// Both sides made the same change — treat as left-only (auto-applicable)
const bStart = Math.min(r.baseStart, paired.baseStart)
const bEnd = Math.max(r.baseEnd, paired.baseEnd)
mergeHunks.push({
id: `hunk-${mergeHunks.length}`,
baseStart: bStart,
baseCount: bEnd - bStart,
leftHunk: leftH,
rightHunk: null,
type: "left-only",
})
} else {
// Conflict
const bStart = Math.min(r.baseStart, paired.baseStart)
const bEnd = Math.max(r.baseEnd, paired.baseEnd)
mergeHunks.push({
id: `hunk-${mergeHunks.length}`,
baseStart: bStart,
baseCount: bEnd - bStart,
leftHunk: r.side === "left" ? r.hunk : paired.hunk,
rightHunk: r.side === "right" ? r.hunk : paired.hunk,
type: "conflict",
})
}
} else {
// Single-side change
mergeHunks.push({
id: `hunk-${mergeHunks.length}`,
baseStart: r.baseStart,
baseCount: r.hunk.baseCount,
leftHunk: r.side === "left" ? r.hunk : null,
rightHunk: r.side === "right" ? r.hunk : null,
type: r.side === "left" ? "left-only" : "right-only",
})
}
}
// Sort by baseStart
mergeHunks.sort((a, b) => a.baseStart - b.baseStart)
return mergeHunks
}
// ---------------------------------------------------------------------------
// Result builder
// ---------------------------------------------------------------------------
export interface AppliedHunkInfo {
id: string
side: "left" | "right"
}
/**
* Build the result content by starting from base and applying
* hunks that have been accepted.
*
* @param base Original base content
* @param hunks All merge hunks
* @param applied Map of hunk id → which side was applied
*/
export function buildResult(
base: string,
hunks: MergeHunk[],
applied: Map<string, "left" | "right">
): string {
const baseLines = base.split("\n")
const result: string[] = []
let baseIdx = 0
// Process hunks in order of baseStart
const sorted = [...hunks].sort((a, b) => a.baseStart - b.baseStart)
for (const hunk of sorted) {
// Copy unchanged base lines before this hunk
while (baseIdx < hunk.baseStart) {
result.push(baseLines[baseIdx])
baseIdx++
}
const appliedSide = applied.get(hunk.id)
if (appliedSide) {
// Apply the chosen side's content
const diffHunk = appliedSide === "left" ? hunk.leftHunk : hunk.rightHunk
if (diffHunk) {
result.push(...diffHunk.newLines)
}
// Skip over the base lines that were replaced
baseIdx = hunk.baseStart + hunk.baseCount
} else {
// Not applied — keep base content
for (let i = 0; i < hunk.baseCount; i++) {
if (baseIdx < baseLines.length) {
result.push(baseLines[baseIdx])
baseIdx++
}
}
}
}
// Copy remaining base lines
while (baseIdx < baseLines.length) {
result.push(baseLines[baseIdx])
baseIdx++
}
return result.join("\n")
}

View File

@@ -0,0 +1,291 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { emit } from "@tauri-apps/api/event"
import { Check, FileWarning, Loader2, X, CheckCheck } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Button } from "@/components/ui/button"
import {
gitListConflicts,
gitConflictFileVersions,
gitResolveConflict,
gitAbortOperation,
gitContinueOperation,
gitStartPullMerge,
} from "@/lib/tauri"
import { languageFromPath } from "@/lib/language-detect"
import { toErrorMessage } from "@/lib/app-error"
import type { GitConflictFileVersions } from "@/lib/types"
import { ThreePaneMergeEditor } from "./three-pane-merge-editor"
interface MergeWorkspaceProps {
folderId: number
folderPath: string
operation: string
onCompleted: () => void
onAborted: () => void
}
export function MergeWorkspace({
folderId,
folderPath,
operation,
onCompleted,
onAborted,
}: MergeWorkspaceProps) {
const t = useTranslations("MergePage")
const [files, setFiles] = useState<string[]>([])
const [resolvedFiles, setResolvedFiles] = useState<Set<string>>(new Set())
const [selectedFile, setSelectedFile] = useState<string | null>(null)
const [versions, setVersions] = useState<GitConflictFileVersions | null>(null)
const [loadingVersions, setLoadingVersions] = useState(false)
const [resolving, setResolving] = useState(false)
const [aborting, setAborting] = useState(false)
const [completing, setCompleting] = useState(false)
const currentContentRef = useRef<string>("")
const [hasUnresolvedConflicts, setHasUnresolvedConflicts] = useState(true)
// Load conflict files on mount
useEffect(() => {
loadConflicts()
}, [folderPath]) // eslint-disable-line react-hooks/exhaustive-deps
async function loadConflicts() {
try {
// For pull operations, the merge was aborted during detection to keep
// working tree clean. Re-start the merge to create conflict state.
if (operation === "pull") {
await gitStartPullMerge(folderPath)
}
const conflictFiles = await gitListConflicts(folderPath)
setFiles(conflictFiles)
if (conflictFiles.length > 0 && !selectedFile) {
selectFile(conflictFiles[0])
}
} catch (err) {
toast.error(toErrorMessage(err))
}
}
async function selectFile(file: string) {
setSelectedFile(file)
setLoadingVersions(true)
try {
const v = await gitConflictFileVersions(folderPath, file)
setVersions(v)
currentContentRef.current = v.base
setHasUnresolvedConflicts(true)
} catch (err) {
toast.error(toErrorMessage(err))
setVersions(null)
} finally {
setLoadingVersions(false)
}
}
const handleContentChange = useCallback((content: string) => {
currentContentRef.current = content
}, [])
const handleConflictStatusChange = useCallback((hasUnresolved: boolean) => {
setHasUnresolvedConflicts(hasUnresolved)
}, [])
async function handleResolve() {
if (!selectedFile) return
const content = currentContentRef.current
if (hasUnresolvedConflicts) {
toast.warning(t("unresolvedConflicts"))
return
}
setResolving(true)
try {
await gitResolveConflict(folderPath, selectedFile, content)
setResolvedFiles((prev) => new Set([...prev, selectedFile]))
// Notify parent window
await emit("folder://merge-conflict-resolved", {
folder_id: folderId,
file: selectedFile,
})
// Auto-select next unresolved file
const nextUnresolved = files.find(
(f) => f !== selectedFile && !resolvedFiles.has(f)
)
if (nextUnresolved) {
selectFile(nextUnresolved)
}
} catch (err) {
toast.error(toErrorMessage(err))
} finally {
setResolving(false)
}
}
async function handleAbort() {
setAborting(true)
try {
await gitAbortOperation(folderPath, operation)
toast.success(t("abortSuccess"))
await emit("folder://merge-completed", { folder_id: folderId })
onAborted()
} catch (err) {
toast.error(toErrorMessage(err))
} finally {
setAborting(false)
}
}
async function handleComplete() {
setCompleting(true)
try {
await gitContinueOperation(folderPath, operation)
toast.success(t("allResolved"))
await emit("folder://merge-completed", { folder_id: folderId })
onCompleted()
} catch (err) {
toast.error(toErrorMessage(err))
} finally {
setCompleting(false)
}
}
const allResolved =
files.length > 0 && files.every((f) => resolvedFiles.has(f))
const language = selectedFile ? languageFromPath(selectedFile) : "plaintext"
return (
<div className="flex h-full flex-col gap-2">
<ResizablePanelGroup
direction="horizontal"
className="flex-1 min-h-0 rounded-lg border"
>
{/* Left sidebar: conflict file list */}
<ResizablePanel defaultSize={18} minSize={12} maxSize={30}>
<div className="flex h-full flex-col">
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground">
{t("conflictFiles")} ({files.length})
</div>
<ScrollArea className="flex-1">
<div className="p-1">
{files.map((file) => {
const isResolved = resolvedFiles.has(file)
const isSelected = file === selectedFile
return (
<button
key={file}
type="button"
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${
isSelected
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50"
}`}
onClick={() => !isResolved && selectFile(file)}
disabled={isResolved}
>
{isResolved ? (
<Check className="h-3 w-3 shrink-0 text-green-500" />
) : (
<FileWarning className="h-3 w-3 shrink-0 text-amber-500" />
)}
<span
className={`truncate ${isResolved ? "text-muted-foreground line-through" : ""}`}
>
{file}
</span>
</button>
)
})}
{files.length === 0 && (
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
{t("noConflicts")}
</div>
)}
</div>
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle />
{/* Main area: three-pane merge editor */}
<ResizablePanel defaultSize={82}>
{loadingVersions ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("loadingFile")}
</div>
) : versions && selectedFile ? (
<ThreePaneMergeEditor
key={selectedFile}
base={versions.base}
ours={versions.ours}
theirs={versions.theirs}
merged={versions.merged}
language={language}
onContentChange={handleContentChange}
onConflictStatusChange={handleConflictStatusChange}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t("selectFile")}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
{/* Bottom toolbar */}
<div className="flex items-center justify-end gap-2">
<Button
variant="secondary"
size="sm"
onClick={handleAbort}
disabled={aborting || completing || resolving}
>
{aborting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
<X className="mr-1 h-3.5 w-3.5" />
{t("abortMerge")}
</Button>
<Button
size="sm"
onClick={handleResolve}
disabled={
!selectedFile ||
resolving ||
aborting ||
completing ||
(selectedFile !== null && resolvedFiles.has(selectedFile))
}
>
{resolving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
<Check className="mr-1 h-3.5 w-3.5" />
{t("markResolved")}
</Button>
{allResolved && (
<Button
size="sm"
onClick={handleComplete}
disabled={completing || aborting}
>
{completing && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
<CheckCheck className="mr-1 h-3.5 w-3.5" />
{t("completeMerge")}
</Button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,758 @@
"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 } from "monaco-editor"
import { ArrowLeft, ArrowRight, CheckCheck } from "lucide-react"
import { useTranslations } from "next-intl"
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
import { cn } from "@/lib/utils"
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"
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 { registerEditor } = useSyncScroll()
const leftEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
null
)
const centerEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
null
)
const rightEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
null
)
// Decorations collections
const leftDecorationsRef =
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null)
const centerDecorationsRef =
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null)
const rightDecorationsRef =
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(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<string, "left" | "right">
>(new Map())
// Track ignored hunks
const [ignoredHunks, setIgnoredHunks] = useState<Set<string>>(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: MonacoEditorNs.IStandaloneEditorConstructionOptions = {
fontSize: 13,
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
lineNumbers: "on",
glyphMargin: true,
folding: false,
wordWrap: "off",
overviewRulerLanes: 0,
}
const readonlyOptions = {
...editorOptions,
readOnly: true,
domReadOnly: true,
}
const loadingEl = (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
Loading editor...
</div>
)
return (
<div className={cn("flex h-full flex-col", className)}>
{/* Header */}
<div className="flex items-center border-b bg-muted/50 px-3 py-1.5">
<div className="text-xs font-medium text-muted-foreground">
{t("localVersion")}
</div>
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
<div className="flex items-center gap-2 text-xs font-medium text-foreground">
{t("result")}
{unresolvedConflicts > 0 && (
<span className="text-red-500">
({unresolvedConflicts}{" "}
{unresolvedConflicts === 1 ? "conflict" : "conflicts"})
</span>
)}
{pendingNonConflicts > 0 && (
<span className="text-amber-500">
({pendingNonConflicts} pending)
</span>
)}
{totalChanges > 0 &&
unresolvedConflicts === 0 &&
pendingNonConflicts === 0 && (
<span className="text-green-500">
<CheckCheck className="inline h-3 w-3" />
</span>
)}
</div>
{pendingNonConflicts > 0 && (
<div className="flex items-center gap-1">
<Button
size="sm"
className="h-5 px-1.5 text-[10px]"
onClick={handleApplyLeftNonConflicting}
>
<ArrowRight className="mr-0.5 h-2.5 w-2.5" />
{t("applyLeftNonConflicting")}
</Button>
<Button
size="sm"
className="h-5 px-1.5 text-[10px]"
onClick={handleApplyAllNonConflicting}
>
{t("applyAllNonConflicting")}
</Button>
<Button
size="sm"
className="h-5 px-1.5 text-[10px]"
onClick={handleApplyRightNonConflicting}
>
{t("applyRightNonConflicting")}
<ArrowLeft className="ml-0.5 h-2.5 w-2.5" />
</Button>
</div>
)}
</div>
<div className="text-xs font-medium text-muted-foreground">
{t("remoteVersion")}
</div>
</div>
{/* Three-panel layout: [left editor + gutter] | center editor | [gutter + right editor] */}
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
{/* Left: Ours (local) + arrow gutter */}
<ResizablePanel defaultSize={34} minSize={15}>
<div className="flex h-full">
<div className="min-w-0 flex-1">
<MonacoEditor
value={ours}
language={language}
theme={editorTheme}
beforeMount={defineMonacoThemes}
onMount={handleLeftMount}
loading={loadingEl}
options={readonlyOptions}
/>
</div>
<ArrowGutter
items={leftGutterItems}
direction="right"
editorRef={leftEditorRef}
scrollTick={scrollTick}
onApply={(id) => applyHunk(id, "left")}
title={t("acceptLocal")}
/>
</div>
</ResizablePanel>
<ResizableHandle />
{/* Center: Result (editable) */}
<ResizablePanel defaultSize={32} minSize={15}>
<MonacoEditor
defaultValue={base}
language={language}
theme={editorTheme}
beforeMount={defineMonacoThemes}
onMount={handleCenterMount}
loading={loadingEl}
options={editorOptions}
/>
</ResizablePanel>
<ResizableHandle />
{/* Right: arrow gutter + Theirs (remote) */}
<ResizablePanel defaultSize={34} minSize={15}>
<div className="flex h-full">
<ArrowGutter
items={rightGutterItems}
direction="left"
editorRef={rightEditorRef}
scrollTick={scrollTick}
onApply={(id) => applyHunk(id, "right")}
title={t("acceptRemote")}
/>
<div className="min-w-0 flex-1">
<MonacoEditor
value={theirs}
language={language}
theme={editorTheme}
beforeMount={defineMonacoThemes}
onMount={handleRightMount}
loading={loadingEl}
options={readonlyOptions}
/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)
}
// ---------------------------------------------------------------------------
// Arrow Gutter Component
// ---------------------------------------------------------------------------
interface ArrowGutterProps {
items: Array<{ hunk: MergeHunk; lineNumber: number }>
direction: "left" | "right"
editorRef: React.RefObject<MonacoEditorNs.IStandaloneCodeEditor | null>
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
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _tick = scrollTick // read to establish dependency
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 (
<div className="merge-gutter-column">
{positioned.map(({ hunk, top }) => (
<button
key={hunk.id}
type="button"
className={cn(
"merge-gutter-arrow-btn",
hunk.type === "conflict"
? "merge-gutter-arrow-conflict"
: "merge-gutter-arrow-accept"
)}
style={{ top: `${top}px` }}
onClick={() => onApply(hunk.id)}
title={title}
>
{direction === "right" ? "\u00BB" : "\u00AB"}
</button>
))}
</div>
)
}
// ---------------------------------------------------------------------------
// 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
): MonacoEditorNs.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
}

View File

@@ -0,0 +1,44 @@
import { useCallback, useRef } from "react"
import type { editor as MonacoEditorNs } from "monaco-editor"
type EditorInstance = MonacoEditorNs.IStandaloneCodeEditor
/**
* Hook to synchronize scrolling between multiple Monaco editors.
* Uses a flag to prevent infinite scroll loops.
*/
export function useSyncScroll() {
const isSyncing = useRef(false)
const editorsRef = useRef<EditorInstance[]>([])
const registerEditor = useCallback(
(editor: EditorInstance, index: number) => {
editorsRef.current[index] = editor
editor.onDidScrollChange(() => {
if (isSyncing.current) return
isSyncing.current = true
const scrollTop = editor.getScrollTop()
const scrollLeft = editor.getScrollLeft()
for (let i = 0; i < editorsRef.current.length; i++) {
if (i !== index && editorsRef.current[i]) {
editorsRef.current[i].setScrollPosition({
scrollTop,
scrollLeft,
})
}
}
// Use rAF to release the sync flag after all scroll events settle
requestAnimationFrame(() => {
isSyncing.current = false
})
})
},
[]
)
return { registerEditor }
}