支持git冲突时弹出窗口合并代码解决冲突
This commit is contained in:
102
src/components/merge/conflict-parser.ts
Normal file
102
src/components/merge/conflict-parser.ts
Normal 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(">>>>>>>")
|
||||
}
|
||||
355
src/components/merge/merge-diff.ts
Normal file
355
src/components/merge/merge-diff.ts
Normal 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")
|
||||
}
|
||||
291
src/components/merge/merge-workspace.tsx
Normal file
291
src/components/merge/merge-workspace.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
758
src/components/merge/three-pane-merge-editor.tsx
Normal file
758
src/components/merge/three-pane-merge-editor.tsx
Normal 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
|
||||
}
|
||||
44
src/components/merge/use-sync-scroll.ts
Normal file
44
src/components/merge/use-sync-scroll.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user