519 lines
16 KiB
JavaScript
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>
|
|
);
|
|
}
|
|
}
|