Trim conflict component render tests (#2313)

* test: trim conflict component render tests

* 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
This commit is contained in:
Jinjing 2026-05-18 23:57:11 -07:00 committed by GitHub
parent 65658fcba6
commit 6805f20696
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 157 additions and 286 deletions

View File

@ -1,172 +1,5 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import type { GitStatusEntry } from '../../../../shared/types'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
ConflictBanner,
ConflictReviewPanel,
getNextConflictNavigationIndex
} from './ConflictComponents'
function createConflictReviewFile(overrides: Partial<OpenFile> = {}): OpenFile {
return {
id: 'repo::/repo::conflict-review',
filePath: '/repo',
relativePath: 'Conflict Review',
worktreeId: 'repo::/repo',
language: 'text',
isDirty: false,
mode: 'conflict-review',
conflictReview: {
source: 'live-summary',
snapshotTimestamp: Date.UTC(2026, 4, 17, 19, 9, 7),
entries: [
{
path: 'src/renderer/src/store/slices/linear.test.ts',
conflictKind: 'both_added'
},
{
path: 'src/renderer/src/store/slices/linear.ts',
conflictKind: 'both_modified'
}
]
},
...overrides
}
}
function createLiveEntry(
path: string,
status: GitStatusEntry['status'] = 'modified'
): GitStatusEntry {
return {
path,
status,
area: 'unstaged',
conflictKind: path.endsWith('.test.ts') ? 'both_added' : 'both_modified',
conflictStatus: 'unresolved',
conflictStatusSource: 'git'
}
}
describe('ConflictReviewPanel', () => {
it('renders unresolved conflicts as a left tree with supplied default content', () => {
const file = createConflictReviewFile()
const html = renderToStaticMarkup(
<ConflictReviewPanel
file={file}
liveEntries={[
createLiveEntry('src/renderer/src/store/slices/linear.test.ts', 'added'),
createLiveEntry('src/renderer/src/store/slices/linear.ts')
]}
onOpenEntry={vi.fn()}
selectedFile={null}
selectedContent={<div data-conflict-review-default-content="true">file contents</div>}
onDismiss={vi.fn()}
onRefreshSnapshot={vi.fn()}
onReturnToSourceControl={vi.fn()}
/>
)
expect(html).toContain('Files')
expect(html).toContain('Collapse file tree')
expect(html).toContain('src/renderer/src/store/slices')
expect(html).toContain('linear.test.ts')
expect(html).toContain('linear.ts')
expect(html).toContain('data-conflict-review-default-content="true"')
expect(html).toContain('file contents')
expect(html).not.toContain('Select a conflict from the file tree')
})
it('renders a lightweight overview when no conflict file is selected', () => {
const file = createConflictReviewFile()
const html = renderToStaticMarkup(
<ConflictReviewPanel
file={file}
liveEntries={[
createLiveEntry('src/renderer/src/store/slices/linear.test.ts', 'added'),
createLiveEntry('src/renderer/src/store/slices/linear.ts')
]}
onOpenEntry={vi.fn()}
selectedFile={null}
selectedContent={null}
onDismiss={vi.fn()}
onRefreshSnapshot={vi.fn()}
onReturnToSourceControl={vi.fn()}
/>
)
expect(html).toContain('Conflicts')
expect(html).toContain('Open a file to resolve markers in the editor.')
expect(html).toContain('Choose which version to keep, or combine them')
expect(html).toContain('Resolve the conflict markers')
expect(html).not.toContain('Loading conflict contents')
})
it('keeps selected conflict content in a flex-height pane', () => {
const file = createConflictReviewFile()
const html = renderToStaticMarkup(
<ConflictReviewPanel
file={file}
liveEntries={[
createLiveEntry('src/renderer/src/store/slices/linear.test.ts', 'added'),
createLiveEntry('src/renderer/src/store/slices/linear.ts')
]}
onOpenEntry={vi.fn()}
selectedFile={{
id: '/repo/src/renderer/src/store/slices/linear.ts',
filePath: '/repo/src/renderer/src/store/slices/linear.ts',
relativePath: 'src/renderer/src/store/slices/linear.ts',
worktreeId: 'repo::/repo',
language: 'typescript',
isDirty: false,
mode: 'edit'
}}
selectedContent={<div data-selected-conflict-content="true" className="flex-1" />}
onDismiss={vi.fn()}
onRefreshSnapshot={vi.fn()}
onReturnToSourceControl={vi.fn()}
/>
)
expect(html).toContain(
'class="flex min-h-0 flex-1 flex-col"><div data-selected-conflict-content="true"'
)
})
})
describe('ConflictBanner', () => {
it('renders conflict navigation controls when conflict markers are available', () => {
const file = createConflictReviewFile({
mode: 'edit',
conflict: {
kind: 'conflict-editable',
conflictKind: 'both_modified',
conflictStatus: 'unresolved',
conflictStatusSource: 'git'
}
})
const html = renderToStaticMarkup(
<TooltipProvider>
<ConflictBanner
file={file}
entry={null}
conflictNavigation={{
currentIndex: 1,
total: 3,
onJump: vi.fn()
}}
/>
</TooltipProvider>
)
expect(html).toContain('Unresolved conflict')
expect(html).toContain('2 / 3')
expect(html).toContain('Previous conflict')
expect(html).toContain('Next conflict')
})
})
import { describe, expect, it } from 'vitest'
import { getNextConflictNavigationIndex } from './ConflictComponents'
describe('getNextConflictNavigationIndex', () => {
it('cycles through conflicts in both directions', () => {

View File

@ -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 (
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Conflicts</div>
<div className="mt-1 text-xs text-muted-foreground">
Open a file to resolve markers in the editor.
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={onReturnToSourceControl}>
<GitMerge className="size-3.5" />
Source Control
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onDismiss}>
<X className="size-3.5" />
Dismiss
</Button>
</div>
</div>
{resolvedCount > 0 && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{resolvedCount} conflict{resolvedCount === 1 ? '' : 's'} no longer live in Git.
</div>
)}
<div className="overflow-hidden rounded-md border border-border">
{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 (
<button
key={entry.path}
type="button"
className={cn(
'group flex w-full min-w-0 items-start gap-3 border-b border-border px-3 py-3 text-left transition-colors last:border-b-0 hover:bg-accent/40 disabled:cursor-default disabled:hover:bg-transparent',
!liveEntry && 'opacity-65'
)}
disabled={!liveEntry}
onClick={() => {
if (liveEntry) {
onOpenEntry(liveEntry)
}
}}
>
<FileIcon
className={cn('mt-0.5 size-4 shrink-0', isUnresolved && 'text-destructive')}
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-all font-mono text-xs text-foreground">
{entry.path}
</span>
<span
className={cn(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
isUnresolved
? 'bg-destructive/12 text-destructive'
: 'bg-muted text-muted-foreground'
)}
>
{statusLabel}
</span>
</div>
<div className="text-xs text-muted-foreground">
{CONFLICT_KIND_LABELS[entry.conflictKind]} ·{' '}
{CONFLICT_HINT_MAP[entry.conflictKind]}
</div>
{liveEntry?.oldPath && (
<div className="text-xs text-muted-foreground">
Renamed from {liveEntry.oldPath}
</div>
)}
</div>
</button>
)
})}
</div>
</div>
</div>
)
}
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({
</div>
<div className="flex min-h-0 flex-1 flex-col">
{selectedContent ?? (
<ConflictReviewOverview
entries={treeEntries}
resolvedCount={resolvedCount}
onOpenEntry={onOpenEntry}
onReturnToSourceControl={onReturnToSourceControl}
onDismiss={onDismiss}
/>
<div className="flex h-full min-h-0 items-center justify-center px-6 text-center text-sm text-muted-foreground">
Loading conflict contents...
</div>
)}
</div>
</div>

View File

@ -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)}
/>
)}
<div className="min-h-0 flex-1">
<div className={autoHeight ? 'shrink-0' : 'min-h-0 flex-1'}>
<MonacoEditor
key={`${viewStateScopeId}:${contentFile.id}:${viewStateKeySuffix}`}
filePath={contentFile.filePath}
@ -478,11 +517,15 @@ export function EditorContent({
relativePath={contentFile.relativePath}
content={selectedContent}
language={monacoSelectedLanguage}
onContentChange={(content) => 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 (
<div className="min-h-0 flex-1 overflow-y-auto bg-editor-surface scrollbar-sleek">
{unresolvedEntries.map(renderConflictReviewInlineFile)}
</div>
)
}
if (activeFile.mode === 'conflict-review') {
return (
<ConflictReviewPanel
@ -526,7 +597,7 @@ export function EditorContent({
selectedContent={
selectedConflictReviewFile
? renderConflictReviewSelectedContent(selectedConflictReviewFile)
: null
: renderConflictReviewAllContent()
}
onDismiss={() => closeFile(activeFile.id)}
onRefreshSnapshot={() =>

View File

@ -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<editor.IStandaloneCodeEditor | null>(null)
const editorContainerRef = useRef<HTMLDivElement | null>(null)
const [mountedEditor, setMountedEditor] = useState<editor.IStandaloneCodeEditor | null>(null)
const [autoHeightContentHeight, setAutoHeightContentHeight] = useState<number | null>(null)
const modelKeyRef = useRef<string | null>(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 (
<div ref={editorContainerRef} className="relative h-full">
<div
ref={editorContainerRef}
className={autoHeight ? 'relative' : 'relative h-full'}
style={renderedEditorHeight === null ? undefined : { height: renderedEditorHeight }}
>
{commentPopover && shouldShowMarkdownAnnotations && (
<DiffCommentPopover
key={commentPopover.lineNumber}
@ -614,7 +657,7 @@ export default function MonacoEditor({
/>
)}
<Editor
height="100%"
height={renderedEditorHeight === null ? '100%' : `${renderedEditorHeight}px`}
language={language}
value={content}
theme={isDark ? 'vs-dark' : 'vs'}
@ -634,6 +677,8 @@ export default function MonacoEditor({
renderLineHighlight: 'line',
automaticLayout: true,
tabSize: 2,
readOnly,
scrollbar: autoHeight ? { vertical: 'hidden', handleMouseWheel: false } : undefined,
smoothScrolling: true,
cursorSmoothCaretAnimation: 'off',
padding: { top: 0 },

View File

@ -4,6 +4,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import { getConnectionId } from '@/lib/connection-context'
import { joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
@ -257,6 +258,32 @@ export function useEditorPanelContentState({
)
useEffect(() => {
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