Merge Conflict Resolver
Resolve git conflict hunks one by one: keep current, incoming or both, preview the merged file, and undo any choice.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/merge-conflict.json'use client'
import { useState } from 'react'
import './merge-conflict.css'
type Seg = string[] | { ours: string[]; theirs: string[] }
const FILE: Seg[] = [
["import { defineConfig } from './define'", '', 'export default defineConfig({'],
{ ours: [" name: 'checkout-api',", ' port: 8080,'], theirs: [" name: 'checkout-service',", ' port: 8080,'] },
[' retries: 3,'],
{ ours: [' timeoutMs: 4500,'], theirs: [' timeoutMs: 3000,', ' circuitBreaker: true,'] },
[' logging: {', " level: 'info',"],
{ ours: [" format: 'json',"], theirs: [" format: 'pretty',", " redact: ['token'],"] },
[' },', '})'],
]
type Pick = 'ours' | 'theirs' | 'both'
export interface MergeConflictProps {
file?: string
defaultMerged?: boolean
onResolve?: (text: string) => void
fill?: 'dark' | 'light'
className?: string
}
/** Hunk-by-hunk git conflict resolver. */
export function MergeConflict({ file = 'src/config.ts', defaultMerged = false, onResolve, fill = 'dark', className }: MergeConflictProps) {
const [picks, setPicks] = useState<Record<number, Pick>>({})
const [merged, setMerged] = useState(defaultMerged)
const [done, setDone] = useState(false)
const hunks = FILE.map((s, i) => (Array.isArray(s) ? -1 : i)).filter((i) => i >= 0)
const left = hunks.filter((i) => !picks[i]).length
const lines = (s: Seg, i: number): string[] => (Array.isArray(s) ? s : picks[i] === 'ours' ? s.ours : picks[i] === 'theirs' ? s.theirs : picks[i] === 'both' ? [...s.ours, ...s.theirs] : [])
const text = FILE.flatMap((s, i) => lines(s, i)).join('\n')
let n = 0
const Ln = (t: string, k: string) => { n++; return <div key={k} className="sd-mgc__ln"><i>{n}</i><span>{t || ' '}</span></div> }
if (done) return <div className={['sd-mgc', className].filter(Boolean).join(' ')} data-fill={fill}><div className="sd-mgc__done" role="status">{file} marked as resolved. Ready to commit.</div><div className="sd-mgc__f"><span /><button type="button" onClick={() => { setDone(false); setPicks({}) }}>Start over</button></div></div>
return (
<div className={['sd-mgc', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-mgc__h"><code>{file}</code><span aria-live="polite">{left ? `${left} of ${hunks.length} conflicts left` : 'All conflicts resolved'}</span></div>
<div className="sd-mgc__body">
{FILE.map((s, i) => {
if (Array.isArray(s)) return s.map((t, j) => Ln(t, `${i}-${j}`))
if (picks[i] || merged) return <div key={i} className={picks[i] ? 'sd-mgc__res' : undefined}>{lines(s, i).map((t, j) => Ln(t, `${i}-${j}`))}{picks[i] && !merged && <button type="button" onClick={() => { const p = { ...picks }; delete p[i]; setPicks(p) }} aria-label={`Undo choice for conflict ${hunks.indexOf(i) + 1}`}>Undo</button>}{!picks[i] && <div className="sd-mgc__ln"><i /><span>{' // unresolved conflict'}</span></div>}</div>
return <div key={i} className="sd-mgc__hk" role="group" aria-label={`Conflict ${hunks.indexOf(i) + 1}`}>
<div className="sd-mgc__side" data-s="ours"><b>Current (HEAD)</b>{s.ours.map((t, j) => <div key={j} className="sd-mgc__ln"><i>‹</i><span>{t}</span></div>)}</div>
<div className="sd-mgc__side" data-s="theirs"><b>Incoming (feature/timeouts)</b>{s.theirs.map((t, j) => <div key={j} className="sd-mgc__ln"><i>›</i><span>{t}</span></div>)}</div>
<div className="sd-mgc__act">{(['ours', 'theirs', 'both'] as Pick[]).map((p) => <button key={p} type="button" onClick={() => setPicks({ ...picks, [i]: p })}>{p === 'ours' ? 'Current' : p === 'theirs' ? 'Incoming' : 'Both'}</button>)}</div>
</div>
})}
</div>
<div className="sd-mgc__f">
<label><input type="checkbox" checked={merged} onChange={(e) => setMerged(e.target.checked)} />Preview merged</label>
<button type="button" disabled={left > 0} onClick={() => { setDone(true); onResolve?.(text) }}>Mark resolved</button>
</div>
</div>
)
}.sd-mgc { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-mgc[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-mgc :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-mgc :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-mgc .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-mgc { width: min(100%, 350px); padding: 12px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 8px; }
.sd-mgc__h { display: flex; justify-content: space-between; align-items: center; font-size: 12px; padding: 0 2px; }
.sd-mgc__h code { font-family: ui-monospace, Menlo, monospace; font-weight: 600; }
.sd-mgc__h span { color: var(--mute); font-size: 11px; }
.sd-mgc__body { max-height: 330px; overflow: auto; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--line); font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
.sd-mgc__ln { display: grid; grid-template-columns: 26px minmax(0, 1fr); white-space: pre; }
.sd-mgc__ln > i { font-style: normal; color: var(--mute); text-align: right; padding-right: 6px; user-select: none; opacity: 0.6; }
.sd-mgc__ln > span { overflow: hidden; text-overflow: ellipsis; padding-right: 8px; }
.sd-mgc__hk { margin: 4px; border-radius: 8px; box-shadow: inset 0 0 0 1px var(--fg); overflow: hidden; animation: sd-mgc-in 200ms ease-out; }
@keyframes sd-mgc-in { from { opacity: 0; } }
.sd-mgc__side { padding: 3px 0; }
.sd-mgc__side + .sd-mgc__side { border-top: 1px dashed var(--line); }
.sd-mgc__side > b { display: block; padding: 0 8px; font: 600 9.5px/1.9 system-ui, sans-serif; color: var(--mute); text-transform: uppercase; letter-spacing: 0.06em; }
.sd-mgc__side[data-s='ours'] { background: var(--soft); }
.sd-mgc__act { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; background: var(--line); border-top: 1px solid var(--line); }
.sd-mgc__act button { height: 28px; background: var(--bg) !important; font: 600 11px system-ui, sans-serif; }
.sd-mgc__act button:hover { background: var(--soft) !important; }
.sd-mgc__res { position: relative; background: var(--soft); }
.sd-mgc__res > button { position: absolute; top: 2px; right: 4px; font: 600 10px system-ui, sans-serif; color: var(--mute) !important; padding: 2px 6px; border-radius: 5px; }
.sd-mgc__res > button:hover { color: var(--fg) !important; background: var(--bg) !important; }
.sd-mgc__f { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.sd-mgc__f button { height: 34px; padding: 0 14px; border-radius: 9px; font-size: 12px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
.sd-mgc__f button:disabled { opacity: 0.35; }
.sd-mgc__f label { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--mute); }
.sd-mgc__f input { accent-color: var(--fg); margin: 0; }
.sd-mgc__done { padding: 18px; text-align: center; font: 600 12px system-ui, sans-serif; }
@media (prefers-reduced-motion: reduce) { .sd-mgc *, .sd-mgc *::before, .sd-mgc *::after { animation: none !important; transition: none !important; } }Overview
Each conflict shows both sides with line markers. Choosing a side folds the hunk into the merged result; Both stacks current above incoming.
The header counts what is left, and the merged preview toggles on so you can read the final file before marking it resolved.
When to use
- Code review tools, git GUIs, teaching version control.
When not to use
- Large files with many conflicts: use a three-way editor.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/merge-conflict.json$
npx shadcn@latest add https://sundaeui.dev/r/merge-conflict.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/merge-conflict.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/merge-conflict.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy merge-conflict.tsx and merge-conflict.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { MergeConflict } from '@/components/sundae/merge-conflict'
<MergeConflict />Props
| Prop | Type | Default |
|---|---|---|
filePath shown in the header. | string | 'src/config.ts' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each hunk is a group with labelled actions.
- Remaining-conflict count is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-mgc; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Merge Conflict Resolver free?
- Yes. Merge Conflict Resolver is free for personal and commercial projects, with full source on this page.
- How do I install Merge Conflict Resolver?
- Run npx shadcn@latest add https://sundaeui.dev/r/merge-conflict.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Merge Conflict Resolver support keyboard and screen readers?
- Each hunk is a group with labelled actions. Remaining-conflict count is announced.
- Does Merge Conflict Resolver respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Fold Accordion
Components · UnderstatedConfirm Dialog
Components · Understated- Pro
Team Members
Components · Understated - Pro
AI Composer
Components · Understated Agent Steps
Components · UnderstatedCopy Command
Components · Understated