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