import { EditorView } from '@codemirror/view'; import { unifiedMergeView } from '@codemirror/merge'; import type { Extension } from '@codemirror/state'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import { useTheme } from '../../../contexts/ThemeContext'; import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument'; import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings'; import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts'; import type { CodeEditorFile } from '../types/types'; import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions'; import { getEditorStyles } from '../utils/editorStyles'; import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel'; import CodeEditorFooter from './subcomponents/CodeEditorFooter'; import CodeEditorHeader from './subcomponents/CodeEditorHeader'; import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState'; import CodeEditorSurface from './subcomponents/CodeEditorSurface'; import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile'; import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview'; type CodeEditorProps = { file: CodeEditorFile; onClose: () => void; projectPath?: string; isSidebar?: boolean; isExpanded?: boolean; onToggleExpand?: (() => void) | null; onPopOut?: (() => void) | null; }; export default function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null, onPopOut = null, }: CodeEditorProps) { const { t } = useTranslation('codeEditor'); const paletteOps = usePaletteOps(); const [isFullscreen, setIsFullscreen] = useState(false); const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo)); const [markdownPreview, setMarkdownPreview] = useState(false); // The code editor follows the app-wide theme; it has no theme of its own. const { isDarkMode } = useTheme(); const { wordWrap, minimapEnabled, showLineNumbers, fontSize, } = useCodeEditorSettings(); const { content, setContent, loading, saving, saveSuccess, saveError, isBinary, previewKind, fileProjectId, handleSave, handleDownload, } = useCodeEditorDocument({ file, projectPath, }); const isMarkdownFile = useMemo(() => { const extension = file.name.split('.').pop()?.toLowerCase(); return extension === 'md' || extension === 'markdown'; }, [file.name]); const isHtmlPreviewFile = useMemo(() => { const extension = file.name.split('.').pop()?.toLowerCase(); return extension === 'html' || extension === 'htm'; }, [file.name]); const openHtmlPreview = useCallback(() => { const previewWindow = window.open('', '_blank'); if (!previewWindow) return; previewWindow.opener = null; previewWindow.document.title = file.name; previewWindow.document.body.style.margin = '0'; const iframe = previewWindow.document.createElement('iframe'); iframe.title = file.name; iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts'); iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white'; iframe.srcdoc = content; previewWindow.document.body.appendChild(iframe); }, [content, file.name]); const minimapExtension = useMemo( () => ( createMinimapExtension({ file, showDiff, minimapEnabled, isDarkMode, }) ), [file, isDarkMode, minimapEnabled, showDiff], ); const scrollToFirstChunkExtension = useMemo( () => createScrollToFirstChunkExtension({ file, showDiff }), [file, showDiff], ); const toolbarPanelExtension = useMemo( () => ( createEditorToolbarPanelExtension({ file, showDiff, isSidebar, isExpanded, onToggleDiff: () => setShowDiff((previous) => !previous), onPopOut, onToggleExpand, labels: { changes: t('toolbar.changes'), previousChange: t('toolbar.previousChange'), nextChange: t('toolbar.nextChange'), hideDiff: t('toolbar.hideDiff'), showDiff: t('toolbar.showDiff'), collapse: t('toolbar.collapse'), expand: t('toolbar.expand'), }, }) ), [file, isExpanded, isSidebar, onPopOut, onToggleExpand, showDiff, t], ); const extensions = useMemo(() => { const allExtensions: Extension[] = [ ...getLanguageExtensions(file.name), ...toolbarPanelExtension, ]; if (file.diffInfo && showDiff && file.diffInfo.old_string !== undefined) { allExtensions.push( unifiedMergeView({ original: file.diffInfo.old_string, mergeControls: false, highlightChanges: true, syntaxHighlightDeletions: false, gutter: true, }), ); allExtensions.push(...minimapExtension); allExtensions.push(...scrollToFirstChunkExtension); } if (wordWrap) { allExtensions.push(EditorView.lineWrapping); } return allExtensions; }, [ file.diffInfo, file.name, minimapExtension, scrollToFirstChunkExtension, showDiff, toolbarPanelExtension, wordWrap, ]); useEditorKeyboardShortcuts({ onSave: handleSave, onClose, dependency: content, }); if (loading) { return ( ); } // Natively previewable media (image/pdf/audio/video) is rendered inline // instead of showing the generic "cannot be displayed" placeholder. if (previewKind) { return ( setIsFullscreen((previous) => !previous)} labels={{ loading: t('filePreview.loading', 'Loading preview...'), error: t('filePreview.error', 'Unable to display this file.'), openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'), fullscreen: t('actions.fullscreen', 'Fullscreen'), exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'), close: t('actions.close', 'Close'), }} /> ); } // Binary file display if (isBinary) { return ( setIsFullscreen((previous) => !previous)} title={t('binaryFile.title', 'Binary File')} message={t('binaryFile.message', 'The file "{{fileName}}" cannot be displayed in the text editor because it is a binary file.', { fileName: file.name })} /> ); } const outerContainerClassName = isSidebar ? 'w-full h-full flex flex-col' : `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`; const innerContainerClassName = isSidebar ? 'bg-background flex flex-col w-full h-full' : `bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl${ isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]' }`; return ( <>
setMarkdownPreview((previous) => !previous)} onOpenHtmlPreview={openHtmlPreview} onOpenSettings={() => paletteOps.openSettings('appearance')} onDownload={handleDownload} onSave={handleSave} onToggleFullscreen={() => setIsFullscreen((previous) => !previous)} onClose={onClose} labels={{ showingChanges: t('header.showingChanges'), editMarkdown: t('actions.editMarkdown'), previewMarkdown: t('actions.previewMarkdown'), previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'), settings: t('toolbar.settings'), download: t('actions.download'), save: t('actions.save'), saving: t('actions.saving'), saved: t('actions.saved'), fullscreen: t('actions.fullscreen'), exitFullscreen: t('actions.exitFullscreen'), close: t('actions.close'), }} /> {saveError && (
{saveError}
)}
); }