From 6eb199a4bf520958e6d43819344f8bed3bf00be0 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 18 May 2026 23:56:42 -0700
Subject: [PATCH] Show unresolved conflicts inline in review panel
- Replace the conflict review overview list with read-only inline Monaco views
- Add auto-height editor rendering so each conflict file fits its contents
- Preload unresolved conflict files for the all-conflicts review view
---
.../components/editor/ConflictComponents.tsx | 111 +-----------------
.../src/components/editor/EditorContent.tsx | 81 ++++++++++++-
.../src/components/editor/MonacoEditor.tsx | 53 ++++++++-
.../editor/useEditorPanelContentState.ts | 27 +++++
4 files changed, 155 insertions(+), 117 deletions(-)
diff --git a/src/renderer/src/components/editor/ConflictComponents.tsx b/src/renderer/src/components/editor/ConflictComponents.tsx
index 5960fe39278..8b6ff42f221 100644
--- a/src/renderer/src/components/editor/ConflictComponents.tsx
+++ b/src/renderer/src/components/editor/ConflictComponents.tsx
@@ -11,7 +11,6 @@ import {
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import { getFileTypeIcon } from '@/lib/file-type-icons'
import { cn } from '@/lib/utils'
import type { ConflictReviewEntry, OpenFile } from '@/store/slices/editor'
import type { GitConflictKind, GitStatusEntry } from '../../../../shared/types'
@@ -185,105 +184,6 @@ export function ConflictPlaceholderView({ file }: { file: OpenFile }): React.JSX
)
}
-function ConflictReviewOverview({
- entries,
- resolvedCount,
- onOpenEntry,
- onReturnToSourceControl,
- onDismiss
-}: {
- entries: readonly ConflictReviewPanelEntry[]
- resolvedCount: number
- onOpenEntry: (entry: GitStatusEntry) => void
- onReturnToSourceControl: () => void
- onDismiss: () => void
-}): React.JSX.Element {
- return (
-
-
-
-
-
Conflicts
-
- Open a file to resolve markers in the editor.
-
-
-
-
-
-
-
- {resolvedCount > 0 && (
-
- {resolvedCount} conflict{resolvedCount === 1 ? '' : 's'} no longer live in Git.
-
- )}
-
- {entries.map((entry) => {
- const FileIcon = getFileTypeIcon(entry.path)
- const liveEntry = entry.liveEntry
- const isUnresolved = liveEntry?.conflictStatus === 'unresolved'
- const statusLabel = isUnresolved ? 'Unresolved' : liveEntry ? 'Resolved' : 'Gone'
-
- return (
-
- )
- })}
-
-
-
- )
-}
-
export function ConflictReviewPanel({
file,
liveEntries,
@@ -323,7 +223,6 @@ export function ConflictReviewPanel({
(entry) => entry.liveEntry?.conflictStatus === 'unresolved'
)
const unresolvedCount = unresolvedSnapshotEntries.length
- const resolvedCount = Math.max(0, snapshotEntries.length - unresolvedCount)
const snapshotTime = new Date(
file.conflictReview?.snapshotTimestamp ?? Date.now()
).toLocaleTimeString()
@@ -401,13 +300,9 @@ export function ConflictReviewPanel({
{selectedContent ?? (
-
+
+ Loading conflict contents...
+
)}
diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx
index b83575666c1..7aa784007b5 100644
--- a/src/renderer/src/components/editor/EditorContent.tsx
+++ b/src/renderer/src/components/editor/EditorContent.tsx
@@ -7,6 +7,7 @@ already live in their own modules. */
import React, { lazy } from 'react'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { detectLanguage } from '@/lib/language-detect'
+import { joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { ChangesModeView } from './ChangesModeView'
@@ -232,6 +233,40 @@ export function EditorContent({
]
)
+ const createConflictReviewContentFile = (entry: GitStatusEntry): OpenFile => {
+ const absolutePath = joinPath(activeFile.filePath, entry.path)
+ const conflict =
+ entry.conflictKind && entry.conflictStatus && entry.conflictStatusSource
+ ? entry.status === 'deleted'
+ ? {
+ kind: 'conflict-placeholder' as const,
+ conflictKind: entry.conflictKind,
+ conflictStatus: entry.conflictStatus,
+ conflictStatusSource: entry.conflictStatusSource,
+ message:
+ 'This file is in a conflict state, but no working-tree file is available to edit.',
+ guidance: 'Resolve the conflict in Git or restore one side before reopening it.'
+ }
+ : {
+ kind: 'conflict-editable' as const,
+ conflictKind: entry.conflictKind,
+ conflictStatus: entry.conflictStatus,
+ conflictStatusSource: entry.conflictStatusSource
+ }
+ : undefined
+
+ return {
+ id: absolutePath,
+ filePath: absolutePath,
+ relativePath: entry.path,
+ worktreeId: activeFile.worktreeId,
+ language: detectLanguage(entry.path),
+ isDirty: false,
+ mode: 'edit',
+ conflict
+ }
+ }
+
const renderMonacoEditor = (fc: FileContent): React.JSX.Element => (
// Why: Without a key, React reuses the same MonacoEditor instance when
// switching tabs or split panes, just updating props. That means
@@ -400,12 +435,16 @@ export function EditorContent({
contentFile,
entry,
className,
- viewStateKeySuffix
+ viewStateKeySuffix,
+ readOnly = false,
+ autoHeight = false
}: {
contentFile: OpenFile
entry: GitStatusEntry | null
className: string
viewStateKeySuffix: string
+ readOnly?: boolean
+ autoHeight?: boolean
}): React.JSX.Element => {
if (contentFile.conflict?.kind === 'conflict-placeholder') {
return (
@@ -470,7 +509,7 @@ export function EditorContent({
conflictNavigation={getConflictNavigation(contentFile, selectedContent)}
/>
)}
-
+
handleContentChangeForFile(contentFile, content)}
- onSave={(content) => handleSaveForFile(contentFile, content)}
+ onContentChange={
+ readOnly ? () => {} : (content) => handleContentChangeForFile(contentFile, content)
+ }
+ onSave={readOnly ? () => {} : (content) => handleSaveForFile(contentFile, content)}
worktreeId={contentFile.worktreeId}
markdownAnnotationsEnabled={false}
conflictDecorationsEnabled={contentFile.conflict?.conflictStatus === 'unresolved'}
+ readOnly={readOnly}
+ autoHeight={autoHeight}
revealLine={
pendingEditorReveal?.filePath === contentFile.filePath
? pendingEditorReveal.line
@@ -516,6 +559,34 @@ export function EditorContent({
})
}
+ const renderConflictReviewInlineFile = (entry: GitStatusEntry): React.JSX.Element => {
+ const contentFile = createConflictReviewContentFile(entry)
+
+ return renderConflictReviewEditorContent({
+ contentFile,
+ entry,
+ className: 'flex min-h-[120px] flex-col border-b border-border last:border-b-0',
+ viewStateKeySuffix: `overview:${entry.path}`,
+ readOnly: true,
+ autoHeight: true
+ })
+ }
+
+ const renderConflictReviewAllContent = (): React.JSX.Element => {
+ const snapshotEntries = activeFile.conflictReview?.entries ?? []
+ const liveEntriesByPath = new Map(worktreeEntries.map((entry) => [entry.path, entry]))
+ const unresolvedEntries = snapshotEntries.flatMap((entry) => {
+ const liveEntry = liveEntriesByPath.get(entry.path)
+ return liveEntry?.conflictStatus === 'unresolved' && liveEntry.conflictKind ? [liveEntry] : []
+ })
+
+ return (
+
+ {unresolvedEntries.map(renderConflictReviewInlineFile)}
+
+ )
+ }
+
if (activeFile.mode === 'conflict-review') {
return (
closeFile(activeFile.id)}
onRefreshSnapshot={() =>
diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx
index 155132b23a3..a78948f3d45 100644
--- a/src/renderer/src/components/editor/MonacoEditor.tsx
+++ b/src/renderer/src/components/editor/MonacoEditor.tsx
@@ -53,6 +53,8 @@ type MonacoEditorProps = {
worktreeId?: string
markdownAnnotationsEnabled?: boolean
conflictDecorationsEnabled?: boolean
+ readOnly?: boolean
+ autoHeight?: boolean
}
export default function MonacoEditor({
@@ -69,11 +71,14 @@ export default function MonacoEditor({
markdownDocuments,
worktreeId,
markdownAnnotationsEnabled = false,
- conflictDecorationsEnabled = false
+ conflictDecorationsEnabled = false,
+ readOnly = false,
+ autoHeight = false
}: MonacoEditorProps): React.JSX.Element {
const editorRef = useRef(null)
const editorContainerRef = useRef(null)
const [mountedEditor, setMountedEditor] = useState(null)
+ const [autoHeightContentHeight, setAutoHeightContentHeight] = useState(null)
const modelKeyRef = useRef(null)
const languageRef = useRef(language)
languageRef.current = language
@@ -113,6 +118,16 @@ export default function MonacoEditor({
settings?.terminalFontSize ?? 13,
editorFontZoomLevel
)
+ const estimatedAutoHeight = useMemo(() => {
+ if (!autoHeight) {
+ return null
+ }
+ const lineHeight = Math.ceil(editorFontSize * 1.45)
+ return Math.max(80, content.split(/\r?\n/).length * lineHeight + 18)
+ }, [autoHeight, content, editorFontSize])
+ const renderedEditorHeight = autoHeight
+ ? (autoHeightContentHeight ?? estimatedAutoHeight ?? 80)
+ : null
// Why: `keepCurrentModel` retains Monaco models across unmounts, and
// @monaco-editor/react skips its value→model sync on the first render after
// a remount. Without explicit sync, external file changes that arrived
@@ -279,6 +294,24 @@ export default function MonacoEditor({
(editorInstance, monaco) => {
editorRef.current = editorInstance
setMountedEditor(editorInstance)
+ let autoHeightSub: { dispose: () => void } | null = null
+ let autoHeightFrame: number | null = null
+ const updateAutoHeight = (): void => {
+ if (!autoHeight) {
+ return
+ }
+ if (autoHeightFrame !== null) {
+ return
+ }
+ autoHeightFrame = window.requestAnimationFrame(() => {
+ autoHeightFrame = null
+ setAutoHeightContentHeight(Math.ceil(editorInstance.getContentHeight()) + 1)
+ })
+ }
+ if (autoHeight) {
+ updateAutoHeight()
+ autoHeightSub = editorInstance.onDidContentSizeChange(updateAutoHeight)
+ }
markdownDocLinkDecorationsRef.current = createMarkdownDocLinkDecorationController(
editorInstance,
() => languageRef.current
@@ -337,6 +370,11 @@ export default function MonacoEditor({
})
editorInstance.onDidDispose(() => {
+ autoHeightSub?.dispose()
+ if (autoHeightFrame !== null) {
+ window.cancelAnimationFrame(autoHeightFrame)
+ autoHeightFrame = null
+ }
conflictDecorationsRef.current?.clear()
conflictDecorationsRef.current = null
editorRef.current = null
@@ -413,7 +451,8 @@ export default function MonacoEditor({
filePath,
setEditorCursorLine,
updateMarkdownCompletionDocuments,
- viewStateKey
+ viewStateKey,
+ autoHeight
]
)
@@ -601,7 +640,11 @@ export default function MonacoEditor({
}, [queueReveal, revealLine, revealColumn, revealMatchLength, setPendingEditorReveal])
return (
-
+
{commentPopover && shouldShowMarkdownAnnotations && (
)}
{
+ if (activeFile?.mode === 'conflict-review' && !selectedConflictReviewFile) {
+ const snapshotEntries = activeFile.conflictReview?.entries ?? []
+ if (snapshotEntries.length === 0) {
+ return
+ }
+
+ const snapshotPaths = new Set(snapshotEntries.map((entry) => entry.path))
+ const liveEntries = gitStatusByWorktree[activeFile.worktreeId] ?? []
+ for (const entry of liveEntries) {
+ if (
+ !snapshotPaths.has(entry.path) ||
+ entry.conflictStatus !== 'unresolved' ||
+ !entry.conflictKind ||
+ entry.status === 'deleted'
+ ) {
+ continue
+ }
+
+ const absolutePath = joinPath(activeFile.filePath, entry.path)
+ if (!fileContents[absolutePath]) {
+ void loadFileContent(absolutePath, absolutePath, activeFile.worktreeId, entry.path)
+ }
+ }
+ return
+ }
+
const fileToLoad = selectedConflictReviewFile ?? activeFile
if (!fileToLoad || (activeFile?.mode === 'conflict-review' && !selectedConflictReviewFile)) {
return