diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 02341c107..9f2d82b5f 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -963,6 +963,51 @@
text-underline-offset: 2px;
}
+/* ── Git conflict decorations ───────────────────────────────────── */
+
+.monaco-editor .orca-conflict-marker-line {
+ background: color-mix(in srgb, var(--destructive) 12%, transparent);
+}
+
+.monaco-editor .orca-conflict-section-line {
+ border-left: 3px solid transparent;
+}
+
+.monaco-editor .orca-conflict-current-line {
+ background: color-mix(in srgb, var(--git-decoration-modified) 12%, transparent);
+ border-left-color: color-mix(in srgb, var(--git-decoration-modified) 72%, transparent);
+}
+
+.monaco-editor .orca-conflict-base-line {
+ background: color-mix(in srgb, var(--muted-foreground) 7%, transparent);
+ border-left-color: color-mix(in srgb, var(--muted-foreground) 36%, transparent);
+}
+
+.monaco-editor .orca-conflict-incoming-line {
+ background: color-mix(in srgb, var(--git-decoration-renamed) 11%, transparent);
+ border-left-color: color-mix(in srgb, var(--git-decoration-renamed) 68%, transparent);
+}
+
+.monaco-editor .orca-conflict-line-decoration {
+ width: 4px !important;
+ margin-left: 3px;
+ background: var(--destructive);
+ border-radius: 999px;
+}
+
+.monaco-editor .orca-conflict-margin {
+ background: color-mix(in srgb, var(--destructive) 8%, transparent);
+}
+
+.monaco-editor .orca-conflict-marker-label {
+ color: var(--destructive);
+ font-family: var(--font-sans, system-ui, sans-serif);
+ font-size: 11px;
+ font-weight: 600;
+ font-style: normal;
+ opacity: 0.92;
+}
+
/* Why: Monaco's find-widget button tooltips (Close, Find in Selection, etc.)
render inside a `.context-view` wrapper → `.workbench-hover-container` →
`.workbench-hover.compact`, positioned above the target button. The wrapper's
diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx
index ac20aec8d..b6bc751f3 100644
--- a/src/renderer/src/components/editor/EditorContent.tsx
+++ b/src/renderer/src/components/editor/EditorContent.tsx
@@ -163,6 +163,7 @@ export function EditorContent({
onSave={isMarkdown ? md.mdSave : handleSave}
worktreeId={activeFile.worktreeId}
markdownAnnotationsEnabled={markdownReviewToolsEnabled && isMarkdown && mdViewMode !== 'rich'}
+ conflictDecorationsEnabled={activeFile.conflict?.conflictStatus === 'unresolved'}
revealLine={
pendingEditorReveal?.filePath === activeFile.filePath ? pendingEditorReveal.line : undefined
}
@@ -194,6 +195,12 @@ export function EditorContent({
viewMode: mdViewMode
})
+ if (activeFile.conflict?.conflictStatus === 'unresolved') {
+ // Why: conflict markers are source text the user must edit directly.
+ // Rich/preview markdown modes can hide or reinterpret those marker lines.
+ return
{renderMonacoEditor(fc)}
+ }
+
// Why: the render-mode helper already folded size into the mode decision.
// Keep the explanatory banner here so the user understands why "rich" view
// currently shows Monaco instead.
diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx
index d033af0b7..3e9129f7c 100644
--- a/src/renderer/src/components/editor/MonacoEditor.tsx
+++ b/src/renderer/src/components/editor/MonacoEditor.tsx
@@ -29,6 +29,7 @@ import {
createMarkdownDocLinkDecorationController,
type MarkdownDocLinkDecorationController
} from './monaco-markdown-doc-link-decorations'
+import { buildGitConflictDecorations, hasGitConflictMarkers } from './monaco-conflict-decorations'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import type { DiffComment } from '../../../../shared/types'
import { isMarkdownComment } from '@/lib/diff-comment-compat'
@@ -50,6 +51,7 @@ type MonacoEditorProps = {
markdownDocuments?: MarkdownDocument[]
worktreeId?: string
markdownAnnotationsEnabled?: boolean
+ conflictDecorationsEnabled?: boolean
}
export default function MonacoEditor({
@@ -65,7 +67,8 @@ export default function MonacoEditor({
revealMatchLength,
markdownDocuments,
worktreeId,
- markdownAnnotationsEnabled = false
+ markdownAnnotationsEnabled = false,
+ conflictDecorationsEnabled = false
}: MonacoEditorProps): React.JSX.Element {
const editorRef = useRef(null)
const editorContainerRef = useRef(null)
@@ -74,6 +77,7 @@ export default function MonacoEditor({
const languageRef = useRef(language)
languageRef.current = language
const markdownDocLinkDecorationsRef = useRef(null)
+ const conflictDecorationsRef = useRef(null)
const revealDecorationRef = useRef(null)
const revealHighlightTimerRef = useRef | null>(null)
const revealRafRef = useRef(null)
@@ -332,6 +336,8 @@ export default function MonacoEditor({
})
editorInstance.onDidDispose(() => {
+ conflictDecorationsRef.current?.clear()
+ conflictDecorationsRef.current = null
editorRef.current = null
setMountedEditor(null)
setCommentPopover(null)
@@ -542,6 +548,27 @@ export default function MonacoEditor({
markdownDocLinkDecorationsRef.current?.refresh()
}, [content, language])
+ useEffect(() => {
+ const ed = mountedEditor
+ if (!ed) {
+ return
+ }
+
+ if (!conflictDecorationsEnabled || !hasGitConflictMarkers(content)) {
+ conflictDecorationsRef.current?.clear()
+ return
+ }
+
+ // Why: Git conflict marker lines are ordinary file text; Monaco needs
+ // explicit decorations so unresolved blocks remain visible while editing.
+ const decorations = buildGitConflictDecorations(content)
+ if (!conflictDecorationsRef.current) {
+ conflictDecorationsRef.current = ed.createDecorationsCollection(decorations)
+ return
+ }
+ conflictDecorationsRef.current.set(decorations)
+ }, [conflictDecorationsEnabled, content, mountedEditor])
+
useEffect(() => {
updateMarkdownCompletionDocuments()
}, [updateMarkdownCompletionDocuments])
@@ -553,6 +580,8 @@ export default function MonacoEditor({
}
markdownDocLinkDecorationsRef.current?.dispose()
markdownDocLinkDecorationsRef.current = null
+ conflictDecorationsRef.current?.clear()
+ conflictDecorationsRef.current = null
}
}, [])
diff --git a/src/renderer/src/components/editor/monaco-conflict-decorations.test.ts b/src/renderer/src/components/editor/monaco-conflict-decorations.test.ts
new file mode 100644
index 000000000..eed08422e
--- /dev/null
+++ b/src/renderer/src/components/editor/monaco-conflict-decorations.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildGitConflictDecorations,
+ findGitConflictBlocks,
+ hasGitConflictMarkers
+} from './monaco-conflict-decorations'
+
+describe('findGitConflictBlocks', () => {
+ it('finds standard conflict marker blocks', () => {
+ const content = [
+ 'before',
+ '<<<<<<< HEAD',
+ 'current',
+ '=======',
+ 'incoming',
+ '>>>>>>> branch',
+ 'after'
+ ].join('\n')
+
+ expect(findGitConflictBlocks(content)).toEqual([
+ {
+ startLine: 2,
+ separatorLine: 4,
+ endLine: 6
+ }
+ ])
+ })
+
+ it('keeps common ancestor markers inside diff3 conflict blocks', () => {
+ const content = [
+ '<<<<<<< HEAD',
+ 'current',
+ '||||||| parent of branch',
+ 'base',
+ '=======',
+ 'incoming',
+ '>>>>>>> branch'
+ ].join('\n')
+
+ expect(findGitConflictBlocks(content)).toEqual([
+ {
+ startLine: 1,
+ baseLine: 3,
+ separatorLine: 5,
+ endLine: 7
+ }
+ ])
+ })
+})
+
+describe('buildGitConflictDecorations', () => {
+ it('builds section and marker decorations for a conflict block', () => {
+ const decorations = buildGitConflictDecorations(
+ ['<<<<<<< HEAD', 'current', '=======', 'incoming', '>>>>>>> branch'].join('\n')
+ )
+
+ expect(decorations).toHaveLength(5)
+ expect(decorations[0]).toMatchObject({
+ range: { startLineNumber: 2, endLineNumber: 2 },
+ options: { className: 'orca-conflict-section-line orca-conflict-current-line' }
+ })
+ expect(decorations[1]).toMatchObject({
+ range: { startLineNumber: 4, endLineNumber: 4 },
+ options: { className: 'orca-conflict-section-line orca-conflict-incoming-line' }
+ })
+ expect(decorations[2]).toMatchObject({
+ range: { startLineNumber: 1, endLineNumber: 1 },
+ options: {
+ className: 'orca-conflict-marker-line',
+ linesDecorationsClassName: 'orca-conflict-line-decoration',
+ after: { content: ' Current change' }
+ }
+ })
+ })
+
+ it('detects incomplete markers without producing bogus ranges', () => {
+ const content = ['<<<<<<< HEAD', 'current'].join('\n')
+
+ expect(hasGitConflictMarkers(content)).toBe(true)
+ expect(buildGitConflictDecorations(content)).toEqual([])
+ })
+})
diff --git a/src/renderer/src/components/editor/monaco-conflict-decorations.ts b/src/renderer/src/components/editor/monaco-conflict-decorations.ts
new file mode 100644
index 000000000..5af2df1ad
--- /dev/null
+++ b/src/renderer/src/components/editor/monaco-conflict-decorations.ts
@@ -0,0 +1,169 @@
+import type { editor, IRange } from 'monaco-editor'
+
+type ConflictBlock = {
+ startLine: number
+ baseLine?: number
+ separatorLine: number
+ endLine: number
+}
+
+type ConflictSection = 'current' | 'base' | 'incoming'
+
+function isGitConflictMarkerLine(line: string): boolean {
+ return (
+ line.startsWith('<<<<<<<') ||
+ line.startsWith('|||||||') ||
+ line === '=======' ||
+ line.startsWith('>>>>>>>')
+ )
+}
+
+function getLineEndColumn(line: string): number {
+ return line.length + 1
+}
+
+function makeWholeLineRange(startLineNumber: number, endLineNumber: number): IRange {
+ return {
+ startLineNumber,
+ startColumn: 1,
+ endLineNumber,
+ endColumn: 1
+ }
+}
+
+function makeMarkerRange(lineNumber: number, line: string): IRange {
+ return {
+ startLineNumber: lineNumber,
+ startColumn: 1,
+ endLineNumber: lineNumber,
+ endColumn: getLineEndColumn(line)
+ }
+}
+
+function makeMarkerDecoration(
+ lineNumber: number,
+ line: string,
+ label: string
+): editor.IModelDeltaDecoration {
+ return {
+ range: makeMarkerRange(lineNumber, line),
+ options: {
+ isWholeLine: true,
+ className: 'orca-conflict-marker-line',
+ linesDecorationsClassName: 'orca-conflict-line-decoration',
+ marginClassName: 'orca-conflict-margin',
+ hoverMessage: { value: label },
+ linesDecorationsTooltip: label,
+ after: {
+ content: ` ${label}`,
+ inlineClassName: 'orca-conflict-marker-label'
+ }
+ }
+ }
+}
+
+function makeSectionDecoration(
+ startLineNumber: number,
+ endLineNumber: number,
+ section: ConflictSection
+): editor.IModelDeltaDecoration | null {
+ if (startLineNumber > endLineNumber) {
+ return null
+ }
+
+ return {
+ range: makeWholeLineRange(startLineNumber, endLineNumber),
+ options: {
+ isWholeLine: true,
+ className: `orca-conflict-section-line orca-conflict-${section}-line`
+ }
+ }
+}
+
+export function findGitConflictBlocks(content: string): ConflictBlock[] {
+ const lines = content.split(/\r?\n/)
+ const blocks: ConflictBlock[] = []
+ let current: {
+ startLine: number
+ baseLine?: number
+ separatorLine?: number
+ } | null = null
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index]
+ const lineNumber = index + 1
+
+ if (line.startsWith('<<<<<<<')) {
+ current = { startLine: lineNumber }
+ continue
+ }
+
+ if (!current) {
+ continue
+ }
+
+ if (line.startsWith('|||||||')) {
+ current.baseLine = lineNumber
+ continue
+ }
+
+ if (line === '=======') {
+ current.separatorLine = lineNumber
+ continue
+ }
+
+ if (line.startsWith('>>>>>>>')) {
+ if (current.separatorLine) {
+ blocks.push({
+ startLine: current.startLine,
+ baseLine: current.baseLine,
+ separatorLine: current.separatorLine,
+ endLine: lineNumber
+ })
+ }
+ current = null
+ }
+ }
+
+ return blocks
+}
+
+export function hasGitConflictMarkers(content: string): boolean {
+ return content.split(/\r?\n/).some(isGitConflictMarkerLine)
+}
+
+export function buildGitConflictDecorations(content: string): editor.IModelDeltaDecoration[] {
+ const lines = content.split(/\r?\n/)
+ const decorations: editor.IModelDeltaDecoration[] = []
+
+ for (const block of findGitConflictBlocks(content)) {
+ const currentEndLine = (block.baseLine ?? block.separatorLine) - 1
+ const baseStartLine = block.baseLine ? block.baseLine + 1 : null
+ const sectionDecorations = [
+ makeSectionDecoration(block.startLine + 1, currentEndLine, 'current'),
+ baseStartLine ? makeSectionDecoration(baseStartLine, block.separatorLine - 1, 'base') : null,
+ makeSectionDecoration(block.separatorLine + 1, block.endLine - 1, 'incoming')
+ ]
+
+ for (const decoration of sectionDecorations) {
+ if (decoration) {
+ decorations.push(decoration)
+ }
+ }
+
+ decorations.push(
+ makeMarkerDecoration(block.startLine, lines[block.startLine - 1] ?? '', 'Current change'),
+ ...(block.baseLine
+ ? [makeMarkerDecoration(block.baseLine, lines[block.baseLine - 1] ?? '', 'Common ancestor')]
+ : []),
+ makeMarkerDecoration(
+ block.separatorLine,
+ lines[block.separatorLine - 1] ?? '',
+ 'Incoming change'
+ ),
+ makeMarkerDecoration(block.endLine, lines[block.endLine - 1] ?? '', 'End conflict')
+ )
+ }
+
+ return decorations
+}