import React, { PureComponent } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; import Chip from '@mui/material/Chip'; import Tooltip from '@mui/material/Tooltip'; import Alert from '@mui/material/Alert'; import SaveRoundedIcon from '@mui/icons-material/SaveRounded'; import UndoRoundedIcon from '@mui/icons-material/UndoRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded'; import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; import WrapTextRoundedIcon from '@mui/icons-material/WrapTextRounded'; import CodeMirror, { EditorView, keymap, scrollPastEnd } from '@uiw/react-codemirror'; import { indentWithTab } from '@codemirror/commands'; import { oneDark } from '@codemirror/theme-one-dark'; import { javascript } from '@codemirror/lang-javascript'; import { json } from '@codemirror/lang-json'; import { html } from '@codemirror/lang-html'; import { css } from '@codemirror/lang-css'; import { markdown } from '@codemirror/lang-markdown'; import { sql } from '@codemirror/lang-sql'; import { xml } from '@codemirror/lang-xml'; import VfsFileIcon from './VfsFileIcon'; import { formatVfsSize } from './vfsUtils'; /** * Returns CodeMirror language extension and human-readable language label * based on fileName extension and MIME type. */ function getLanguageInfo(fileName, mime) { if (!fileName && !mime) return { extension: null, label: 'Plain Text' }; const ext = fileName ? fileName.split('.').pop()?.toLowerCase() : ''; switch (ext) { case 'js': case 'jsx': case 'mjs': case 'cjs': return { extension: javascript({ jsx: true }), label: 'JavaScript' }; case 'ts': case 'tsx': return { extension: javascript({ jsx: true, typescript: true }), label: 'TypeScript' }; case 'json': case 'json5': return { extension: json(), label: 'JSON' }; case 'html': case 'htm': return { extension: html(), label: 'HTML' }; case 'css': case 'scss': case 'less': return { extension: css(), label: 'CSS' }; case 'md': case 'markdown': return { extension: markdown(), label: 'Markdown' }; case 'sql': return { extension: sql(), label: 'SQL' }; case 'xml': case 'svg': return { extension: xml(), label: ext === 'svg' ? 'SVG' : 'XML' }; default: if (mime?.includes('json')) return { extension: json(), label: 'JSON' }; if (mime?.includes('javascript')) return { extension: javascript({ jsx: true }), label: 'JavaScript' }; if (mime?.includes('html')) return { extension: html(), label: 'HTML' }; if (mime?.includes('xml') || mime?.includes('svg')) return { extension: xml(), label: 'XML' }; if (mime?.includes('markdown')) return { extension: markdown(), label: 'Markdown' }; return { extension: null, label: 'Plain Text' }; } } // Softer dark theme for the editor code area (One Dark slate #282c34 / #21252b) const EDITOR_BG = '#282c34'; const GUTTER_BG = '#21252b'; const customEditorTheme = EditorView.theme({ '&': { height: '100%', maxHeight: '100%', fontSize: '13px', fontFamily: 'Consolas, Monaco, "Courier New", monospace', backgroundColor: `${EDITOR_BG} !important`, color: '#abb2bf', }, '.cm-scroller': { overflow: 'auto !important', height: '100% !important', maxHeight: '100%', fontFamily: 'inherit', lineHeight: '21px', }, '.cm-content': { padding: '10px 0', }, '.cm-gutters': { backgroundColor: `${GUTTER_BG} !important`, color: '#636d83 !important', borderRight: '1px solid rgba(255, 255, 255, 0.08) !important', }, '.cm-lineNumbers .cm-gutterElement': { padding: '0 10px 0 16px', minWidth: '40px', }, '.cm-activeLine': { backgroundColor: 'rgba(255, 255, 255, 0.04) !important', }, '.cm-activeLineGutter': { backgroundColor: 'rgba(255, 255, 255, 0.07) !important', color: '#abb2bf !important', }, '&.cm-focused': { outline: 'none', }, }); export default class VfsTextEditor extends PureComponent { constructor(props) { super(props); this.editorView = null; this.state = { wrapLines: true, cursorLine: 1, cursorCol: 1, }; } componentDidMount() { this.focusEditor(); } componentDidUpdate(prevProps) { if (prevProps.fileName !== this.props.fileName) { this.focusEditor(); } } handleEditorCreate = (view) => { this.editorView = view; this.focusEditor(); }; focusEditor = () => { setTimeout(() => { this.editorView?.focus(); }, 50); }; toggleWrap = () => { this.setState((prev) => ({ wrapLines: !prev.wrapLines })); }; handleViewUpdate = (viewUpdate) => { if (viewUpdate.selectionSet || viewUpdate.docChanged) { const state = viewUpdate.state; const head = state.selection.main.head; const line = state.doc.lineAt(head); const cursorLine = line.number; const cursorCol = head - line.from + 1; if (this.state.cursorLine !== cursorLine || this.state.cursorCol !== cursorCol) { this.setState({ cursorLine, cursorCol }); } } }; handleMentionInChat = () => { const view = this.editorView; let lineInfo = null; if (view) { const { from, to } = view.state.selection.main; if (typeof from === 'number' && typeof to === 'number' && to > from) { const startLine = view.state.doc.lineAt(from).number; const endLine = view.state.doc.lineAt(to).number; const selectedText = view.state.sliceDoc(from, to); lineInfo = { startLine, endLine, selectedText, }; } } if (this.props.onMentionInChat) { this.props.onMentionInChat(lineInfo); } }; handleRevert = () => { if (window.confirm('Möchtest du alle ungespeicherten Änderungen verwerfen?')) { this.props.onChange(this.props.originalContent); } }; handleSaveShortcut = () => { if (this.props.isDirty && !this.props.isSaving) { this.props.onSave?.(); } }; render() { const { fileName, file, content, isDirty, isSaving, saveError, loadingContent, onChange, onSave, onClose, onMentionInChat, isApplet, onShowRunner, } = this.props; const { wrapLines, cursorLine, cursorCol } = this.state; const text = content || ''; const lineCount = text.split('\n').length; const charCount = text.length; const { extension: langExt, label: langLabel } = getLanguageInfo(fileName, file?.mime); // Build CodeMirror extensions list const extensions = [ customEditorTheme, scrollPastEnd(), keymap.of([ indentWithTab, { key: 'Mod-s', run: () => { this.handleSaveShortcut(); return true; }, }, ]), ]; if (langExt) { extensions.push(langExt); } if (wrapLines) { extensions.push(EditorView.lineWrapping); } return ( theme.palette.mode === 'dark' ? '#14171a' : '#f8f9fa', overflow: 'hidden', }} > {/* Editor Top Bar (Standard GUI Theme) */} {/* File Name & Status */} {fileName} {isDirty && ( )} {/* Action Buttons */} {isApplet && onShowRunner && ( )} {onMentionInChat && ( )} {isDirty && ( )} {/* Error notification if save failed */} {saveError && ( {saveError} )} {/* Editor Area (Softer Dark Slate Code Background) */} {loadingContent ? ( Lade Dateiinhalt… ) : ( )} {/* Status Bar (Standard GUI Theme) */} Zeile {cursorLine}, Spalte {cursorCol} {lineCount} Zeilen {charCount} Zeichen {formatVfsSize(charCount)} {langLabel} UTF-8 {file?.mime || 'text/plain'} ); } }