This commit is contained in:
seb
2026-10-15 01:45:53 +02:00
parent 6abadac120
commit 360660b850
2 changed files with 276 additions and 162 deletions

View File

@@ -5,22 +5,124 @@ 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 Alert from '@mui/material/Alert';
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.textareaRef = React.createRef();
this.gutterRef = React.createRef();
this.editorView = null;
this.state = {
wrapLines: true,
cursorLine: 1,
@@ -38,57 +140,47 @@ export default class VfsTextEditor extends PureComponent {
}
}
focusEditor = () => {
setTimeout(() => {
this.textareaRef.current?.focus();
}, 50);
handleEditorCreate = (view) => {
this.editorView = view;
this.focusEditor();
};
handleScroll = () => {
if (this.textareaRef.current && this.gutterRef.current) {
this.gutterRef.current.scrollTop = this.textareaRef.current.scrollTop;
}
focusEditor = () => {
setTimeout(() => {
this.editorView?.focus();
}, 50);
};
toggleWrap = () => {
this.setState((prev) => ({ wrapLines: !prev.wrapLines }));
};
handleCursorMove = () => {
const ta = this.textareaRef.current;
if (!ta) return;
const pos = ta.selectionStart || 0;
const textBefore = (this.props.content || '').substring(0, pos);
const lines = textBefore.split('\n');
const line = lines.length;
const col = lines[lines.length - 1].length + 1;
if (this.state.cursorLine !== line || this.state.cursorCol !== col) {
this.setState({ cursorLine: line, cursorCol: col });
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 ta = this.textareaRef.current;
const view = this.editorView;
let lineInfo = null;
if (ta) {
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (typeof start === 'number' && typeof end === 'number' && end > start) {
const content = this.props.content || '';
const beforeStart = content.substring(0, start);
const startLine = beforeStart.split('\n').length;
let effectiveEnd = end;
if (effectiveEnd > start && content[effectiveEnd - 1] === '\n') {
effectiveEnd -= 1;
}
const beforeEnd = content.substring(0, effectiveEnd);
const endLine = Math.max(startLine, beforeEnd.split('\n').length);
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: content.substring(start, end),
selectedText,
};
}
}
@@ -98,49 +190,17 @@ export default class VfsTextEditor extends PureComponent {
}
};
handleKeyDown = (e) => {
// Ctrl+S or Cmd+S -> Save
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
if (this.props.isDirty && !this.props.isSaving) {
this.props.onSave();
}
return;
}
// Tab key -> Insert 2 spaces
if (e.key === 'Tab') {
e.preventDefault();
const ta = this.textareaRef.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const val = this.props.content;
const next = val.substring(0, start) + ' ' + val.substring(end);
this.props.onChange(next);
setTimeout(() => {
ta.selectionStart = ta.selectionEnd = start + 2;
}, 0);
}
};
handleRevert = () => {
if (window.confirm('Möchtest du alle ungespeicherten Änderungen verwerfen?')) {
this.props.onChange(this.props.originalContent);
}
};
renderLineNumbers(lineCount) {
const lines = [];
for (let i = 1; i <= lineCount; i++) {
lines.push(
<div key={i} style={{ height: '21px', lineHeight: '21px' }}>
{i}
</div>
);
handleSaveShortcut = () => {
if (this.props.isDirty && !this.props.isSaving) {
this.props.onSave?.();
}
return lines;
}
};
render() {
const {
@@ -151,6 +211,7 @@ export default class VfsTextEditor extends PureComponent {
isSaving,
saveError,
loadingContent,
onChange,
onSave,
onClose,
onMentionInChat,
@@ -158,8 +219,36 @@ export default class VfsTextEditor extends PureComponent {
onShowRunner,
} = this.props;
const lineCount = (content || '').split('\n').length;
const charCount = (content || '').length;
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
@@ -172,7 +261,7 @@ export default class VfsTextEditor extends PureComponent {
overflow: 'hidden',
}}
>
{/* Editor Top Bar */}
{/* Editor Top Bar (Standard GUI Theme) */}
<Box
sx={{
p: 1.25,
@@ -189,7 +278,12 @@ export default class VfsTextEditor extends PureComponent {
>
{/* 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 }}>
<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" />
@@ -232,16 +326,43 @@ export default class VfsTextEditor extends PureComponent {
Exe
</Button>
)}
{onMentionInChat && (
<IconButton size="small" onClick={this.handleMentionInChat} aria-label="In Chat erwähnen" sx={{ p: 0.6 }}>
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
<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 && (
<IconButton size="small" onClick={this.handleRevert} aria-label="Änderungen verwerfen" sx={{ p: 0.6, color: 'text.secondary' }}>
<UndoRoundedIcon fontSize="small" />
</IconButton>
<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
@@ -262,7 +383,12 @@ export default class VfsTextEditor extends PureComponent {
{isSaving ? 'Speichern…' : 'Speichern (Strg+S)'}
</Button>
<IconButton size="small" onClick={onClose} aria-label="Editor schließen" sx={{ p: 0.6, ml: 0.5 }}>
<IconButton
size="small"
onClick={onClose}
aria-label="Editor schließen"
sx={{ p: 0.6, ml: 0.5 }}
>
<CloseRoundedIcon fontSize="small" />
</IconButton>
</Box>
@@ -275,95 +401,87 @@ export default class VfsTextEditor extends PureComponent {
</Alert>
)}
{/* Editor Area */}
<Box sx={{ flexGrow: 1, display: 'flex', minHeight: 0, position: 'relative' }}>
{/* Editor Area (Softer Dark Slate Code Background) */}
<Box sx={{ flexGrow: 1, minHeight: 0, position: 'relative', overflow: 'hidden', bgcolor: EDITOR_BG }}>
{loadingContent ? (
<Box
sx={{
flexGrow: 1,
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
color: 'text.secondary',
color: '#abb2bf',
}}
>
<CircularProgress size={24} />
<Typography variant="body2">Lade Dateiinhalt…</Typography>
</Box>
) : (
<>
{/* Line Numbers Gutter (shown when wrap is off to maintain line alignment) */}
{!this.state.wrapLines && (
<Box
ref={this.gutterRef}
sx={(theme) => ({
width: 44,
flexShrink: 0,
bgcolor: theme.palette.mode === 'dark' ? '#0f1215' : '#f0f2f5',
color: 'text.disabled',
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
fontSize: '13px',
textAlign: 'right',
pr: 1.2,
pt: 1.5,
pb: 3,
userSelect: 'none',
overflow: 'hidden',
borderRight: 1,
borderColor: 'divider',
})}
>
{this.renderLineNumbers(lineCount)}
</Box>
)}
{/* Textarea Code Input */}
<Box
component="textarea"
ref={this.textareaRef}
value={content || ''}
onChange={(e) => {
this.props.onChange(e.target.value);
this.handleCursorMove();
}}
onClick={this.handleCursorMove}
onKeyUp={this.handleCursorMove}
onSelect={this.handleCursorMove}
onScroll={this.handleScroll}
onKeyDown={this.handleKeyDown}
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
wrap={this.state.wrapLines ? 'soft' : 'off'}
sx={{
flexGrow: 1,
<Box
sx={{
position: 'absolute',
inset: 0,
height: '100%',
width: '100%',
overflow: 'hidden',
'& .cm-theme': {
height: '100%',
p: 1.5,
border: 'none',
outline: 'none',
resize: 'none',
bgcolor: 'transparent',
color: 'text.primary',
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
fontSize: '13px',
lineHeight: '21px',
tabSize: 2,
whiteSpace: this.state.wrapLines ? 'pre-wrap' : 'pre',
wordBreak: this.state.wrapLines ? 'break-word' : 'normal',
overflowWrap: this.state.wrapLines ? 'anywhere' : 'normal',
overflowX: this.state.wrapLines ? 'hidden' : 'auto',
overflowY: 'auto',
'&:focus': {
outline: 'none',
},
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 */}
{/* Status Bar (Standard GUI Theme) */}
<Box
sx={{
px: 2,
@@ -381,7 +499,7 @@ export default class VfsTextEditor extends PureComponent {
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span>
Zeile {this.state.cursorLine}, Spalte {this.state.cursorCol}
Zeile {cursorLine}, Spalte {cursorCol}
</span>
<span>{lineCount} Zeilen</span>
<span>{charCount} Zeichen</span>
@@ -389,6 +507,7 @@ export default class VfsTextEditor extends PureComponent {
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span>{langLabel}</span>
<span>UTF-8</span>
<span>{file?.mime || 'text/plain'}</span>
</Box>