fix: address review findings (#2311)
This commit is contained in:
parent
789060c239
commit
833a707182
|
|
@ -2,7 +2,12 @@ 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 { ConflictReviewPanel } from './ConflictComponents'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ConflictBanner,
|
||||
ConflictReviewPanel,
|
||||
getNextConflictNavigationIndex
|
||||
} from './ConflictComponents'
|
||||
|
||||
function createConflictReviewFile(overrides: Partial<OpenFile> = {}): OpenFile {
|
||||
return {
|
||||
|
|
@ -46,7 +51,35 @@ function createLiveEntry(
|
|||
}
|
||||
|
||||
describe('ConflictReviewPanel', () => {
|
||||
it('renders unresolved conflicts as a left file tree', () => {
|
||||
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
|
||||
|
|
@ -64,12 +97,91 @@ describe('ConflictReviewPanel', () => {
|
|||
/>
|
||||
)
|
||||
|
||||
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('Select a conflict from the file tree')
|
||||
expect(html).not.toContain('Choose which version to keep, or combine them')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextConflictNavigationIndex', () => {
|
||||
it('cycles through conflicts in both directions', () => {
|
||||
expect(
|
||||
getNextConflictNavigationIndex({ currentIndex: null, direction: 'next', total: 3 })
|
||||
).toBe(0)
|
||||
expect(getNextConflictNavigationIndex({ currentIndex: 2, direction: 'next', total: 3 })).toBe(0)
|
||||
expect(
|
||||
getNextConflictNavigationIndex({ currentIndex: 0, direction: 'previous', total: 3 })
|
||||
).toBe(2)
|
||||
expect(
|
||||
getNextConflictNavigationIndex({ currentIndex: null, direction: 'previous', total: 3 })
|
||||
).toBe(2)
|
||||
expect(getNextConflictNavigationIndex({ currentIndex: 0, direction: 'next', total: 0 })).toBe(
|
||||
null
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import React from 'react'
|
||||
import { CircleCheck, GitMerge, PanelLeftOpen, RefreshCw, TriangleAlert, X } from 'lucide-react'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CircleCheck,
|
||||
GitMerge,
|
||||
PanelLeftOpen,
|
||||
RefreshCw,
|
||||
TriangleAlert,
|
||||
X
|
||||
} 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'
|
||||
|
|
@ -29,13 +39,41 @@ export const CONFLICT_HINT_MAP: Record<GitConflictKind, string> = {
|
|||
|
||||
const EMPTY_CONFLICT_REVIEW_ENTRIES: readonly ConflictReviewEntry[] = []
|
||||
let conflictReviewFileTreeCollapsedPreference = false
|
||||
type ConflictNavigationDirection = 'previous' | 'next'
|
||||
type ConflictReviewPanelEntry = ConflictReviewEntry & {
|
||||
liveEntry?: GitStatusEntry
|
||||
}
|
||||
|
||||
export function getNextConflictNavigationIndex({
|
||||
currentIndex,
|
||||
direction,
|
||||
total
|
||||
}: {
|
||||
currentIndex: number | null
|
||||
direction: ConflictNavigationDirection
|
||||
total: number
|
||||
}): number | null {
|
||||
if (total <= 0) {
|
||||
return null
|
||||
}
|
||||
if (currentIndex === null || currentIndex < 0 || currentIndex >= total) {
|
||||
return direction === 'previous' ? total - 1 : 0
|
||||
}
|
||||
return direction === 'previous' ? (currentIndex + total - 1) % total : (currentIndex + 1) % total
|
||||
}
|
||||
|
||||
export function ConflictBanner({
|
||||
file,
|
||||
entry
|
||||
entry,
|
||||
conflictNavigation
|
||||
}: {
|
||||
file: OpenFile
|
||||
entry: GitStatusEntry | null
|
||||
conflictNavigation?: {
|
||||
currentIndex: number | null
|
||||
total: number
|
||||
onJump: (direction: ConflictNavigationDirection) => void
|
||||
}
|
||||
}): React.JSX.Element | null {
|
||||
const conflict = file.conflict
|
||||
if (!conflict) {
|
||||
|
|
@ -54,15 +92,58 @@ export function ConflictBanner({
|
|||
: 'border-emerald-500/20 bg-emerald-500/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isUnresolved ? (
|
||||
<TriangleAlert className="size-3.5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<CircleCheck className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{isUnresolved ? (
|
||||
<TriangleAlert className="size-3.5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<CircleCheck className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-medium text-foreground">
|
||||
{label} conflict · {CONFLICT_KIND_LABELS[conflict.conflictKind]}
|
||||
</span>
|
||||
{conflictNavigation && conflictNavigation.total > 0 && (
|
||||
<span className="shrink-0 px-1 text-[11px] tabular-nums text-muted-foreground">
|
||||
{(conflictNavigation.currentIndex ?? 0) + 1} / {conflictNavigation.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{conflictNavigation && conflictNavigation.total > 0 && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Previous conflict"
|
||||
onClick={() => conflictNavigation.onJump('previous')}
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Previous conflict
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Next conflict"
|
||||
onClick={() => conflictNavigation.onJump('next')}
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Next conflict
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium text-foreground">
|
||||
{label} conflict · {CONFLICT_KIND_LABELS[conflict.conflictKind]}
|
||||
</span>
|
||||
</div>
|
||||
{/* Why: the hint is omitted here because the file is already open in the
|
||||
editor below. Showing "Open and edit…" or similar would be confusing
|
||||
|
|
@ -104,6 +185,105 @@ 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,
|
||||
|
|
@ -131,7 +311,7 @@ export function ConflictReviewPanel({
|
|||
() => new Map(liveEntries.map((entry) => [entry.path, entry])),
|
||||
[liveEntries]
|
||||
)
|
||||
const treeEntries = React.useMemo(
|
||||
const treeEntries = React.useMemo<readonly ConflictReviewPanelEntry[]>(
|
||||
() =>
|
||||
snapshotEntries.map((entry) => ({
|
||||
...entry,
|
||||
|
|
@ -219,41 +399,15 @@ export function ConflictReviewPanel({
|
|||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{selectedContent ?? (
|
||||
<div className="flex h-full min-h-0 items-center justify-center px-6 text-center">
|
||||
<div className="max-w-md space-y-3">
|
||||
<div className="mx-auto flex size-10 items-center justify-center rounded-md border border-border bg-card text-muted-foreground">
|
||||
<GitMerge className="size-4" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
Select a conflict from the file tree
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Open each unresolved file, edit the conflict markers, then refresh this snapshot.
|
||||
</div>
|
||||
{resolvedCount > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{resolvedCount} conflict{resolvedCount === 1 ? '' : 's'} no longer live in Git.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-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>
|
||||
</div>
|
||||
<ConflictReviewOverview
|
||||
entries={treeEntries}
|
||||
resolvedCount={resolvedCount}
|
||||
onOpenEntry={onOpenEntry}
|
||||
onReturnToSourceControl={onReturnToSourceControl}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ import { detectLanguage } from '@/lib/language-detect'
|
|||
import { useAppStore } from '@/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChangesModeView } from './ChangesModeView'
|
||||
import { ConflictBanner, ConflictPlaceholderView, ConflictReviewPanel } from './ConflictComponents'
|
||||
import {
|
||||
ConflictBanner,
|
||||
ConflictPlaceholderView,
|
||||
ConflictReviewPanel,
|
||||
getNextConflictNavigationIndex
|
||||
} from './ConflictComponents'
|
||||
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
|
||||
import type { GitStatusEntry, GitDiffResult } from '../../../../shared/types'
|
||||
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
|
||||
|
|
@ -19,6 +24,7 @@ import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
|
|||
import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter'
|
||||
import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary'
|
||||
import { useMarkdownDocuments } from './useMarkdownDocuments'
|
||||
import { findGitConflictBlocks } from './monaco-conflict-decorations'
|
||||
|
||||
const MonacoEditor = lazy(() => import('./MonacoEditor'))
|
||||
const DiffViewer = lazy(() => import('./DiffViewer'))
|
||||
|
|
@ -146,6 +152,10 @@ export function EditorContent({
|
|||
const openConflictReview = useAppStore((s) => s.openConflictReview)
|
||||
const closeFile = useAppStore((s) => s.closeFile)
|
||||
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
|
||||
const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal)
|
||||
const [conflictNavigationIndexByFile, setConflictNavigationIndexByFile] = React.useState<
|
||||
Record<string, number>
|
||||
>({})
|
||||
const md = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave)
|
||||
const activeConflictEntry =
|
||||
worktreeEntries.find((entry) => entry.path === activeFile.relativePath) ?? null
|
||||
|
|
@ -160,6 +170,68 @@ export function EditorContent({
|
|||
activeFile.diffSource === 'combined-branch' ||
|
||||
activeFile.diffSource === 'combined-commit')
|
||||
|
||||
const getConflictNavigation = React.useCallback(
|
||||
(file: OpenFile, content: string) => {
|
||||
const blocks = findGitConflictBlocks(content)
|
||||
if (blocks.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const currentIndex = conflictNavigationIndexByFile[file.id] ?? null
|
||||
return {
|
||||
currentIndex,
|
||||
total: blocks.length,
|
||||
onJump: (direction: 'previous' | 'next') => {
|
||||
const nextIndex = getNextConflictNavigationIndex({
|
||||
currentIndex,
|
||||
direction,
|
||||
total: blocks.length
|
||||
})
|
||||
if (nextIndex === null) {
|
||||
return
|
||||
}
|
||||
const line = blocks[nextIndex].startLine
|
||||
const markerLine = content.split(/\r?\n/)[line - 1] ?? ''
|
||||
setConflictNavigationIndexByFile((prev) => ({ ...prev, [file.id]: nextIndex }))
|
||||
// Why: a same-location reveal can be requested twice before Monaco
|
||||
// consumes the first one. Clearing first guarantees the prop changes
|
||||
// and the mounted editor runs its reveal effect again.
|
||||
setPendingEditorReveal(null)
|
||||
queueMicrotask(() => {
|
||||
setPendingEditorReveal({
|
||||
filePath: file.filePath,
|
||||
line,
|
||||
column: 1,
|
||||
matchLength: markerLine.length
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[conflictNavigationIndexByFile, setPendingEditorReveal]
|
||||
)
|
||||
const openConflictEntry = React.useCallback(
|
||||
(entry: GitStatusEntry) => {
|
||||
if (activeFile.mode !== 'conflict-review') {
|
||||
return
|
||||
}
|
||||
openConflictReviewFile(
|
||||
activeFile.id,
|
||||
activeFile.worktreeId,
|
||||
activeFile.filePath,
|
||||
entry,
|
||||
detectLanguage(entry.path)
|
||||
)
|
||||
},
|
||||
[
|
||||
activeFile.filePath,
|
||||
activeFile.id,
|
||||
activeFile.mode,
|
||||
activeFile.worktreeId,
|
||||
openConflictReviewFile
|
||||
]
|
||||
)
|
||||
|
||||
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
|
||||
|
|
@ -324,77 +396,105 @@ export function EditorContent({
|
|||
return <div className="h-full min-h-0">{renderMonacoEditor(fc)}</div>
|
||||
}
|
||||
|
||||
const renderConflictReviewSelectedContent = (selectedFile: OpenFile): React.JSX.Element => {
|
||||
if (selectedFile.conflict?.kind === 'conflict-placeholder') {
|
||||
return <ConflictPlaceholderView file={selectedFile} />
|
||||
const renderConflictReviewEditorContent = ({
|
||||
contentFile,
|
||||
entry,
|
||||
className,
|
||||
viewStateKeySuffix
|
||||
}: {
|
||||
contentFile: OpenFile
|
||||
entry: GitStatusEntry | null
|
||||
className: string
|
||||
viewStateKeySuffix: string
|
||||
}): React.JSX.Element => {
|
||||
if (contentFile.conflict?.kind === 'conflict-placeholder') {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ConflictPlaceholderView file={contentFile} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const fc = fileContents[selectedFile.id]
|
||||
const fc = fileContents[contentFile.id]
|
||||
if (!fc) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Loading...
|
||||
<div className={className}>
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (fc.loadError) {
|
||||
return (
|
||||
<FileLoadErrorView message={fc.loadError} onRetry={() => reloadFileContent(selectedFile)} />
|
||||
<div className={className}>
|
||||
<FileLoadErrorView
|
||||
message={fc.loadError}
|
||||
onRetry={() => reloadFileContent(contentFile)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (fc.isBinary) {
|
||||
if (fc.isImage) {
|
||||
return (
|
||||
<ImageViewer
|
||||
content={fc.content}
|
||||
filePath={selectedFile.filePath}
|
||||
mimeType={fc.mimeType}
|
||||
/>
|
||||
<div className={className}>
|
||||
<ImageViewer
|
||||
content={fc.content}
|
||||
filePath={contentFile.filePath}
|
||||
mimeType={fc.mimeType}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Binary file — cannot display
|
||||
<div className={className}>
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Binary file — cannot display
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedConflictEntry =
|
||||
worktreeEntries.find((entry) => entry.path === selectedFile.relativePath) ?? null
|
||||
const selectedLanguage = detectLanguage(selectedFile.relativePath)
|
||||
const selectedLanguage = detectLanguage(contentFile.relativePath)
|
||||
const monacoSelectedLanguage = selectedLanguage === 'notebook' ? 'json' : selectedLanguage
|
||||
const selectedViewStateKey = `${selectedFile.filePath}::${viewStateScopeId}`
|
||||
const selectedViewStateKey = `${contentFile.filePath}::${viewStateScopeId}:${viewStateKeySuffix}`
|
||||
const selectedContent = editBuffers[contentFile.id] ?? fc.content
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{selectedFile.conflict && (
|
||||
<ConflictBanner file={selectedFile} entry={selectedConflictEntry} />
|
||||
<div className={className}>
|
||||
{contentFile.conflict && (
|
||||
<ConflictBanner
|
||||
file={contentFile}
|
||||
entry={entry}
|
||||
conflictNavigation={getConflictNavigation(contentFile, selectedContent)}
|
||||
/>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
<MonacoEditor
|
||||
key={`${viewStateScopeId}:${selectedFile.id}`}
|
||||
filePath={selectedFile.filePath}
|
||||
key={`${viewStateScopeId}:${contentFile.id}:${viewStateKeySuffix}`}
|
||||
filePath={contentFile.filePath}
|
||||
viewStateKey={selectedViewStateKey}
|
||||
relativePath={selectedFile.relativePath}
|
||||
content={editBuffers[selectedFile.id] ?? fc.content}
|
||||
relativePath={contentFile.relativePath}
|
||||
content={selectedContent}
|
||||
language={monacoSelectedLanguage}
|
||||
onContentChange={(content) => handleContentChangeForFile(selectedFile, content)}
|
||||
onSave={(content) => handleSaveForFile(selectedFile, content)}
|
||||
worktreeId={selectedFile.worktreeId}
|
||||
onContentChange={(content) => handleContentChangeForFile(contentFile, content)}
|
||||
onSave={(content) => handleSaveForFile(contentFile, content)}
|
||||
worktreeId={contentFile.worktreeId}
|
||||
markdownAnnotationsEnabled={false}
|
||||
conflictDecorationsEnabled={selectedFile.conflict?.conflictStatus === 'unresolved'}
|
||||
conflictDecorationsEnabled={contentFile.conflict?.conflictStatus === 'unresolved'}
|
||||
revealLine={
|
||||
pendingEditorReveal?.filePath === selectedFile.filePath
|
||||
pendingEditorReveal?.filePath === contentFile.filePath
|
||||
? pendingEditorReveal.line
|
||||
: undefined
|
||||
}
|
||||
revealColumn={
|
||||
pendingEditorReveal?.filePath === selectedFile.filePath
|
||||
pendingEditorReveal?.filePath === contentFile.filePath
|
||||
? pendingEditorReveal.column
|
||||
: undefined
|
||||
}
|
||||
revealMatchLength={
|
||||
pendingEditorReveal?.filePath === selectedFile.filePath
|
||||
pendingEditorReveal?.filePath === contentFile.filePath
|
||||
? pendingEditorReveal.matchLength
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -404,20 +504,24 @@ export function EditorContent({
|
|||
)
|
||||
}
|
||||
|
||||
const renderConflictReviewSelectedContent = (selectedFile: OpenFile): React.JSX.Element => {
|
||||
const selectedConflictEntry =
|
||||
worktreeEntries.find((entry) => entry.path === selectedFile.relativePath) ?? null
|
||||
|
||||
return renderConflictReviewEditorContent({
|
||||
contentFile: selectedFile,
|
||||
entry: selectedConflictEntry,
|
||||
className: 'flex min-h-0 flex-1 flex-col',
|
||||
viewStateKeySuffix: 'selected'
|
||||
})
|
||||
}
|
||||
|
||||
if (activeFile.mode === 'conflict-review') {
|
||||
return (
|
||||
<ConflictReviewPanel
|
||||
file={activeFile}
|
||||
liveEntries={worktreeEntries}
|
||||
onOpenEntry={(entry) =>
|
||||
openConflictReviewFile(
|
||||
activeFile.id,
|
||||
activeFile.worktreeId,
|
||||
activeFile.filePath,
|
||||
entry,
|
||||
detectLanguage(entry.path)
|
||||
)
|
||||
}
|
||||
onOpenEntry={openConflictEntry}
|
||||
selectedFile={selectedConflictReviewFile}
|
||||
selectedContent={
|
||||
selectedConflictReviewFile
|
||||
|
|
@ -540,7 +644,16 @@ export function EditorContent({
|
|||
}
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{activeFile.conflict && <ConflictBanner file={activeFile} entry={activeConflictEntry} />}
|
||||
{activeFile.conflict && (
|
||||
<ConflictBanner
|
||||
file={activeFile}
|
||||
entry={activeConflictEntry}
|
||||
conflictNavigation={getConflictNavigation(
|
||||
activeFile,
|
||||
editBuffers[activeFile.id] ?? fc.content
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 relative">
|
||||
{isMarkdown ? (
|
||||
renderMarkdownContent(fc)
|
||||
|
|
|
|||
|
|
@ -81,7 +81,12 @@ export function useEditorPanelContentState({
|
|||
: null
|
||||
|
||||
const loadFileContent = useCallback(
|
||||
async (filePath: string, id: string, worktreeId?: string): Promise<void> => {
|
||||
async (
|
||||
filePath: string,
|
||||
id: string,
|
||||
worktreeId?: string,
|
||||
relativePath?: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const connectionId = getConnectionId(worktreeId ?? null) ?? undefined
|
||||
const restoredOpenFile = openFilesRef.current.find((file) => file.id === id)
|
||||
|
|
@ -108,7 +113,7 @@ export function useEditorPanelContentState({
|
|||
pending = readRuntimeFileContent({
|
||||
settings: readSettings,
|
||||
filePath,
|
||||
relativePath: restoredOpenFile?.relativePath,
|
||||
relativePath: restoredOpenFile?.relativePath ?? relativePath,
|
||||
worktreeId,
|
||||
connectionId
|
||||
}) as Promise<FileContent>
|
||||
|
|
@ -246,7 +251,7 @@ export function useEditorPanelContentState({
|
|||
delete next[file.id]
|
||||
return next
|
||||
})
|
||||
void loadFileContent(file.filePath, file.id, file.worktreeId)
|
||||
void loadFileContent(file.filePath, file.id, file.worktreeId, file.relativePath)
|
||||
},
|
||||
[loadFileContent]
|
||||
)
|
||||
|
|
@ -281,8 +286,10 @@ export function useEditorPanelContentState({
|
|||
activeFile?.id,
|
||||
activeFile?.mode,
|
||||
activeFile?.conflictReview?.selectedFileId,
|
||||
activeFile?.conflictReview?.snapshotTimestamp,
|
||||
selectedConflictReviewFile?.id,
|
||||
isChangesMode
|
||||
isChangesMode,
|
||||
gitStatusByWorktree
|
||||
])
|
||||
|
||||
useEditorPanelFileLoadRetry({
|
||||
|
|
|
|||
Loading…
Reference in New Issue