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

@@ -27,7 +27,6 @@ import {
formatDate, formatDate,
loadParties, loadParties,
manifestConfig, manifestConfig,
numberDisplay,
partyDisplayName, partyDisplayName,
} from './manifestConfig'; } from './manifestConfig';
@@ -90,7 +89,6 @@ class QuoteRow extends PureComponent {
<ListItemButton disabled divider sx={rowSx}> <ListItemButton disabled divider sx={rowSx}>
<Box sx={{ width: 120, flexShrink: 0 }}> <Box sx={{ width: 120, flexShrink: 0 }}>
<Skeleton variant="text" width={80} height={20} /> <Skeleton variant="text" width={80} height={20} />
<Skeleton variant="text" width={50} height={16} />
</Box> </Box>
<Box sx={{ width: 100, flexShrink: 0 }}> <Box sx={{ width: 100, flexShrink: 0 }}>
<Skeleton variant="text" width={70} height={20} /> <Skeleton variant="text" width={70} height={20} />
@@ -125,9 +123,6 @@ class QuoteRow extends PureComponent {
<Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}> <Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}>
{quote.number} {quote.number}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{numberDisplay(quote.externalNumber)}
</Typography>
</Box> </Box>
<Box sx={{ width: 100, flexShrink: 0, minWidth: 0 }}> <Box sx={{ width: 100, flexShrink: 0, minWidth: 0 }}>
@@ -593,7 +588,7 @@ export class QuoteList extends PureComponent {
userSelect: 'none', userSelect: 'none',
}} }}
> >
<Box sx={{ width: 120, flexShrink: 0 }}>Nr. / Ref.</Box> <Box sx={{ width: 120, flexShrink: 0 }}>Nr.</Box>
<Box sx={{ width: 100, flexShrink: 0 }}>Datum</Box> <Box sx={{ width: 100, flexShrink: 0 }}>Datum</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>Kunde</Box> <Box sx={{ flex: 1, minWidth: 0 }}>Kunde</Box>
<Box sx={{ width: 40, flexShrink: 0, textAlign: 'center' }}>Pos.</Box> <Box sx={{ width: 40, flexShrink: 0, textAlign: 'center' }}>Pos.</Box>

View File

@@ -5,22 +5,124 @@ import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Chip from '@mui/material/Chip'; 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 SaveRoundedIcon from '@mui/icons-material/SaveRounded';
import UndoRoundedIcon from '@mui/icons-material/UndoRounded'; import UndoRoundedIcon from '@mui/icons-material/UndoRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded'; import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; 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 VfsFileIcon from './VfsFileIcon';
import { formatVfsSize } from './vfsUtils'; 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 { export default class VfsTextEditor extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.textareaRef = React.createRef(); this.editorView = null;
this.gutterRef = React.createRef();
this.state = { this.state = {
wrapLines: true, wrapLines: true,
cursorLine: 1, cursorLine: 1,
@@ -38,57 +140,47 @@ export default class VfsTextEditor extends PureComponent {
} }
} }
focusEditor = () => { handleEditorCreate = (view) => {
setTimeout(() => { this.editorView = view;
this.textareaRef.current?.focus(); this.focusEditor();
}, 50);
}; };
handleScroll = () => { focusEditor = () => {
if (this.textareaRef.current && this.gutterRef.current) { setTimeout(() => {
this.gutterRef.current.scrollTop = this.textareaRef.current.scrollTop; this.editorView?.focus();
} }, 50);
}; };
toggleWrap = () => { toggleWrap = () => {
this.setState((prev) => ({ wrapLines: !prev.wrapLines })); this.setState((prev) => ({ wrapLines: !prev.wrapLines }));
}; };
handleCursorMove = () => { handleViewUpdate = (viewUpdate) => {
const ta = this.textareaRef.current; if (viewUpdate.selectionSet || viewUpdate.docChanged) {
if (!ta) return; const state = viewUpdate.state;
const pos = ta.selectionStart || 0; const head = state.selection.main.head;
const textBefore = (this.props.content || '').substring(0, pos); const line = state.doc.lineAt(head);
const lines = textBefore.split('\n'); const cursorLine = line.number;
const line = lines.length; const cursorCol = head - line.from + 1;
const col = lines[lines.length - 1].length + 1; if (this.state.cursorLine !== cursorLine || this.state.cursorCol !== cursorCol) {
if (this.state.cursorLine !== line || this.state.cursorCol !== col) { this.setState({ cursorLine, cursorCol });
this.setState({ cursorLine: line, cursorCol: col }); }
} }
}; };
handleMentionInChat = () => { handleMentionInChat = () => {
const ta = this.textareaRef.current; const view = this.editorView;
let lineInfo = null; let lineInfo = null;
if (ta) { if (view) {
const start = ta.selectionStart; const { from, to } = view.state.selection.main;
const end = ta.selectionEnd; if (typeof from === 'number' && typeof to === 'number' && to > from) {
if (typeof start === 'number' && typeof end === 'number' && end > start) { const startLine = view.state.doc.lineAt(from).number;
const content = this.props.content || ''; const endLine = view.state.doc.lineAt(to).number;
const beforeStart = content.substring(0, start); const selectedText = view.state.sliceDoc(from, to);
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);
lineInfo = { lineInfo = {
startLine, startLine,
endLine, 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 = () => { handleRevert = () => {
if (window.confirm('Möchtest du alle ungespeicherten Änderungen verwerfen?')) { if (window.confirm('Möchtest du alle ungespeicherten Änderungen verwerfen?')) {
this.props.onChange(this.props.originalContent); this.props.onChange(this.props.originalContent);
} }
}; };
renderLineNumbers(lineCount) { handleSaveShortcut = () => {
const lines = []; if (this.props.isDirty && !this.props.isSaving) {
for (let i = 1; i <= lineCount; i++) { this.props.onSave?.();
lines.push(
<div key={i} style={{ height: '21px', lineHeight: '21px' }}>
{i}
</div>
);
} }
return lines; };
}
render() { render() {
const { const {
@@ -151,6 +211,7 @@ export default class VfsTextEditor extends PureComponent {
isSaving, isSaving,
saveError, saveError,
loadingContent, loadingContent,
onChange,
onSave, onSave,
onClose, onClose,
onMentionInChat, onMentionInChat,
@@ -158,8 +219,36 @@ export default class VfsTextEditor extends PureComponent {
onShowRunner, onShowRunner,
} = this.props; } = this.props;
const lineCount = (content || '').split('\n').length; const { wrapLines, cursorLine, cursorCol } = this.state;
const charCount = (content || '').length; 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 ( return (
<Box <Box
@@ -172,7 +261,7 @@ export default class VfsTextEditor extends PureComponent {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{/* Editor Top Bar */} {/* Editor Top Bar (Standard GUI Theme) */}
<Box <Box
sx={{ sx={{
p: 1.25, p: 1.25,
@@ -189,7 +278,12 @@ export default class VfsTextEditor extends PureComponent {
> >
{/* File Name & Status */} {/* File Name & Status */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0, mr: 1 }}> <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" /> <ArrowBackRoundedIcon fontSize="small" />
</IconButton> </IconButton>
<VfsFileIcon fileName={fileName} mime={file?.mime} fontSize="small" /> <VfsFileIcon fileName={fileName} mime={file?.mime} fontSize="small" />
@@ -232,16 +326,43 @@ export default class VfsTextEditor extends PureComponent {
Exe Exe
</Button> </Button>
)} )}
{onMentionInChat && (
<IconButton size="small" onClick={this.handleMentionInChat} aria-label="In Chat erwähnen" sx={{ p: 0.6 }}> <Tooltip title={wrapLines ? 'Zeilenumbruch ausschalten' : 'Zeilenumbruch einschalten'}>
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" /> <IconButton
size="small"
onClick={this.toggleWrap}
color={wrapLines ? 'primary' : 'default'}
aria-label="Zeilenumbruch umschalten"
sx={{ p: 0.6 }}
>
<WrapTextRoundedIcon fontSize="small" />
</IconButton> </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 && ( {isDirty && (
<IconButton size="small" onClick={this.handleRevert} aria-label="Änderungen verwerfen" sx={{ p: 0.6, color: 'text.secondary' }}> <Tooltip title="Änderungen verwerfen">
<UndoRoundedIcon fontSize="small" /> <IconButton
</IconButton> size="small"
onClick={this.handleRevert}
aria-label="Änderungen verwerfen"
sx={{ p: 0.6, color: 'text.secondary' }}
>
<UndoRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
)} )}
<Button <Button
@@ -262,7 +383,12 @@ export default class VfsTextEditor extends PureComponent {
{isSaving ? 'Speichern…' : 'Speichern (Strg+S)'} {isSaving ? 'Speichern…' : 'Speichern (Strg+S)'}
</Button> </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" /> <CloseRoundedIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </Box>
@@ -275,95 +401,87 @@ export default class VfsTextEditor extends PureComponent {
</Alert> </Alert>
)} )}
{/* Editor Area */} {/* Editor Area (Softer Dark Slate Code Background) */}
<Box sx={{ flexGrow: 1, display: 'flex', minHeight: 0, position: 'relative' }}> <Box sx={{ flexGrow: 1, minHeight: 0, position: 'relative', overflow: 'hidden', bgcolor: EDITOR_BG }}>
{loadingContent ? ( {loadingContent ? (
<Box <Box
sx={{ sx={{
flexGrow: 1, position: 'absolute',
inset: 0,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 1.5, gap: 1.5,
color: 'text.secondary', color: '#abb2bf',
}} }}
> >
<CircularProgress size={24} /> <CircularProgress size={24} />
<Typography variant="body2">Lade Dateiinhalt…</Typography> <Typography variant="body2">Lade Dateiinhalt…</Typography>
</Box> </Box>
) : ( ) : (
<> <Box
{/* Line Numbers Gutter (shown when wrap is off to maintain line alignment) */} sx={{
{!this.state.wrapLines && ( position: 'absolute',
<Box inset: 0,
ref={this.gutterRef} height: '100%',
sx={(theme) => ({ width: '100%',
width: 44, overflow: 'hidden',
flexShrink: 0, '& .cm-theme': {
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,
height: '100%', height: '100%',
p: 1.5, width: '100%',
border: 'none', },
outline: 'none', '& .cm-editor': {
resize: 'none', height: '100%',
bgcolor: 'transparent', width: '100%',
color: 'text.primary', backgroundColor: EDITOR_BG,
fontFamily: 'Consolas, Monaco, "Courier New", monospace', },
fontSize: '13px', '& .cm-scroller': {
lineHeight: '21px', height: '100% !important',
tabSize: 2, overflow: 'auto !important',
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', <CodeMirror
overflowY: 'auto', value={text}
'&:focus': { height="100%"
outline: 'none', 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> </Box>
{/* Status Bar */} {/* Status Bar (Standard GUI Theme) */}
<Box <Box
sx={{ sx={{
px: 2, px: 2,
@@ -381,7 +499,7 @@ export default class VfsTextEditor extends PureComponent {
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span> <span>
Zeile {this.state.cursorLine}, Spalte {this.state.cursorCol} Zeile {cursorLine}, Spalte {cursorCol}
</span> </span>
<span>{lineCount} Zeilen</span> <span>{lineCount} Zeilen</span>
<span>{charCount} Zeichen</span> <span>{charCount} Zeichen</span>
@@ -389,6 +507,7 @@ export default class VfsTextEditor extends PureComponent {
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span>{langLabel}</span>
<span>UTF-8</span> <span>UTF-8</span>
<span>{file?.mime || 'text/plain'}</span> <span>{file?.mime || 'text/plain'}</span>
</Box> </Box>