Files
dashfront/src/components/vfs/VfsTextEditor.jsx
2026-10-15 01:45:53 +02:00

519 lines
16 KiB
JavaScript

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 (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? '#14171a' : '#f8f9fa',
overflow: 'hidden',
}}
>
{/* Editor Top Bar (Standard GUI Theme) */}
<Box
sx={{
p: 1.25,
px: 2,
borderBottom: 1,
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: 'background.paper',
flexShrink: 0,
minHeight: 48,
}}
>
{/* File Name & Status */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0, mr: 1 }}>
<IconButton
size="small"
onClick={onClose}
aria-label="Zurück zur Dateiliste"
sx={{ p: 0.5, mr: 0.25 }}
>
<ArrowBackRoundedIcon fontSize="small" />
</IconButton>
<VfsFileIcon fileName={fileName} mime={file?.mime} fontSize="small" />
<Typography
variant="body2"
fontWeight={700}
noWrap
sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
>
{fileName}
</Typography>
{isDirty && (
<Chip
label="Ungespeichert"
size="small"
color="warning"
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, borderRadius: 1 }}
/>
)}
</Box>
{/* Action Buttons */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
{isApplet && onShowRunner && (
<Button
size="small"
variant="outlined"
color="success"
startIcon={<PlayArrowRoundedIcon />}
onClick={onShowRunner}
sx={{
borderRadius: 2,
px: 1.5,
py: 0.4,
fontSize: '0.78rem',
fontWeight: 700,
}}
>
Exe
</Button>
)}
<Tooltip title={wrapLines ? 'Zeilenumbruch ausschalten' : 'Zeilenumbruch einschalten'}>
<IconButton
size="small"
onClick={this.toggleWrap}
color={wrapLines ? 'primary' : 'default'}
aria-label="Zeilenumbruch umschalten"
sx={{ p: 0.6 }}
>
<WrapTextRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
{onMentionInChat && (
<Tooltip title="Im Chat erwähnen (inkl. Selektion)">
<IconButton
size="small"
onClick={this.handleMentionInChat}
aria-label="In Chat erwähnen"
sx={{ p: 0.6 }}
>
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
</IconButton>
</Tooltip>
)}
{isDirty && (
<Tooltip title="Änderungen verwerfen">
<IconButton
size="small"
onClick={this.handleRevert}
aria-label="Änderungen verwerfen"
sx={{ p: 0.6, color: 'text.secondary' }}
>
<UndoRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Button
size="small"
variant="contained"
color={isDirty ? 'primary' : 'inherit'}
startIcon={isSaving ? <CircularProgress size={14} color="inherit" /> : <SaveRoundedIcon />}
onClick={onSave}
disabled={!isDirty || isSaving}
sx={{
borderRadius: 2,
px: 1.5,
py: 0.4,
fontSize: '0.78rem',
fontWeight: 600,
}}
>
{isSaving ? 'Speichern…' : 'Speichern (Strg+S)'}
</Button>
<IconButton
size="small"
onClick={onClose}
aria-label="Editor schließen"
sx={{ p: 0.6, ml: 0.5 }}
>
<CloseRoundedIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{/* Error notification if save failed */}
{saveError && (
<Alert severity="error" sx={{ py: 0.5, px: 2, borderRadius: 0, fontSize: '0.78rem' }}>
{saveError}
</Alert>
)}
{/* Editor Area (Softer Dark Slate Code Background) */}
<Box sx={{ flexGrow: 1, minHeight: 0, position: 'relative', overflow: 'hidden', bgcolor: EDITOR_BG }}>
{loadingContent ? (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
color: '#abb2bf',
}}
>
<CircularProgress size={24} />
<Typography variant="body2">Lade Dateiinhalt…</Typography>
</Box>
) : (
<Box
sx={{
position: 'absolute',
inset: 0,
height: '100%',
width: '100%',
overflow: 'hidden',
'& .cm-theme': {
height: '100%',
width: '100%',
},
'& .cm-editor': {
height: '100%',
width: '100%',
backgroundColor: EDITOR_BG,
},
'& .cm-scroller': {
height: '100% !important',
overflow: 'auto !important',
},
}}
>
<CodeMirror
value={text}
height="100%"
style={{ height: '100%', width: '100%' }}
theme={oneDark}
extensions={extensions}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightSpecialChars: true,
history: true,
foldGutter: true,
drawSelection: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
syntaxHighlighting: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
rectangularSelection: true,
crosshairCursor: false,
highlightActiveLine: true,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
defaultKeymap: true,
searchKeymap: true,
historyKeymap: true,
foldKeymap: true,
completionKeymap: true,
lintKeymap: true,
}}
onCreateEditor={this.handleEditorCreate}
onUpdate={this.handleViewUpdate}
onChange={onChange}
/>
</Box>
)}
</Box>
{/* Status Bar (Standard GUI Theme) */}
<Box
sx={{
px: 2,
py: 0.4,
borderTop: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: '0.72rem',
color: 'text.secondary',
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span>
Zeile {cursorLine}, Spalte {cursorCol}
</span>
<span>{lineCount} Zeilen</span>
<span>{charCount} Zeichen</span>
<span>{formatVfsSize(charCount)}</span>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span>{langLabel}</span>
<span>UTF-8</span>
<span>{file?.mime || 'text/plain'}</span>
</Box>
</Box>
</Box>
);
}
}