This commit is contained in:
seb
2026-10-07 13:10:23 +02:00
parent e4927f5d8a
commit 6c0b1503e8
14 changed files with 2644 additions and 86 deletions

View File

@@ -26,10 +26,13 @@ import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded';
import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded';
import DirectionsRunRoundedIcon from '@mui/icons-material/DirectionsRunRounded';
import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded';
import { setting as settingApi } from '../../api';
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
import userSettingsAutomat from '../../automats/userSettingsAutomat';
import vfsAutomat from '../../automats/vfsAutomat';
import { VfsEditorPanel } from '../vfs';
import ChatFlowList from './ChatFlowList';
import AskUserPrompt from './AskUserPrompt';
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
@@ -43,12 +46,16 @@ const DEFAULT_DRAWER_WIDTH = 460;
const MIN_DRAWER_WIDTH = 320;
export default class ChatDrawer extends PureComponent {
constructor(props) {
super(props);
const s = chatAutomat.state;
const settings = userSettingsAutomat.state;
const vfs = vfsAutomat.state;
this.state = {
isOpen: s.isOpen,
isVfsOpen: vfs.isOpen,
vfsWidth: vfs.vfsWidth || 480,
conversations: s.conversations,
selectedConversationId: s.selectedConversationId,
draftConversation: s.draftConversation,
@@ -123,12 +130,18 @@ export default class ChatDrawer extends PureComponent {
}
return null;
});
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
isVfsOpen: state.isOpen,
vfsWidth: state.vfsWidth,
}));
}
componentWillUnmount() {
if (this.focusTimeout) clearTimeout(this.focusTimeout);
this.unsub?.();
this.unsubSettings?.();
this.unsubVfs?.();
this.cleanupResizeListeners();
}
@@ -136,7 +149,14 @@ export default class ChatDrawer extends PureComponent {
chatAutomat.actions.closeChat();
};
handleResizeStart = (e) => {
handleInsertToChat = (text) => {
this.setState((prev) => ({
inputText: prev.inputText ? `${prev.inputText}\n${text}` : text,
}));
this.focusInput(50);
};
handleVfsResizerStart = (e) => {
if (e.button !== undefined && e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
@@ -144,25 +164,36 @@ export default class ChatDrawer extends PureComponent {
const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX);
if (startX === undefined) return;
const startWidth = this.state.drawerWidth;
this.setState({ isResizing: true });
const startVfsWidth = this.state.vfsWidth;
const startChatWidth = this.state.drawerWidth;
const totalPairWidth = startVfsWidth + startChatWidth;
this.setState({ isResizing: true });
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
let currentWidth = startWidth;
let currentChatWidth = startChatWidth;
let currentVfsWidth = startVfsWidth;
const onMove = (moveEvt) => {
moveEvt.preventDefault();
const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX);
if (clientX === undefined) return;
const deltaX = startX - clientX;
const maxWidth = Math.min(window.innerWidth - 40, 1400);
const newWidth = Math.max(MIN_DRAWER_WIDTH, Math.min(maxWidth, Math.round(startWidth + deltaX)));
const deltaX = clientX - startX;
const minVfs = 260;
const minChat = MIN_DRAWER_WIDTH; // 320
currentWidth = newWidth;
this.setState({ drawerWidth: newWidth });
// Drag right (deltaX > 0): VFS increases, Chat decreases
// Drag left (deltaX < 0): VFS decreases, Chat increases
const clampedVfs = Math.max(minVfs, Math.min(totalPairWidth - minChat, Math.round(startVfsWidth + deltaX)));
const clampedChat = totalPairWidth - clampedVfs;
currentVfsWidth = clampedVfs;
currentChatWidth = clampedChat;
vfsAutomat.actions.setVfsWidth(clampedVfs);
this.setState({ drawerWidth: clampedChat });
};
const onEnd = () => {
@@ -177,7 +208,75 @@ export default class ChatDrawer extends PureComponent {
window.removeEventListener('touchend', onEnd);
this.setState({ isResizing: false });
userSettingsAutomat.actions.setChatDrawerWidth(currentWidth);
vfsAutomat.actions.setVfsWidth(currentVfsWidth);
userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth);
};
window.addEventListener('pointermove', onMove, { passive: false });
window.addEventListener('pointerup', onEnd);
window.addEventListener('pointercancel', onEnd);
window.addEventListener('mousemove', onMove, { passive: false });
window.addEventListener('mouseup', onEnd);
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onEnd);
};
handleResizeStart = (e) => {
if (e.button !== undefined && e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX);
if (startX === undefined) return;
const startWidth = this.state.drawerWidth;
const startVfsWidth = this.state.vfsWidth;
const isVfs = this.state.isVfsOpen;
this.setState({ isResizing: true });
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
let currentChatWidth = startWidth;
let currentVfsWidth = startVfsWidth;
const onMove = (moveEvt) => {
moveEvt.preventDefault();
const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX);
if (clientX === undefined) return;
const deltaX = startX - clientX;
if (isVfs) {
const maxVfs = Math.min(window.innerWidth - startWidth - 40, 1200);
const newVfsWidth = Math.max(260, Math.min(maxVfs, Math.round(startVfsWidth + deltaX)));
currentVfsWidth = newVfsWidth;
vfsAutomat.actions.setVfsWidth(newVfsWidth);
} else {
const maxWidth = Math.min(window.innerWidth - 40, 1400);
const newWidth = Math.max(MIN_DRAWER_WIDTH, Math.min(maxWidth, Math.round(startWidth + deltaX)));
currentChatWidth = newWidth;
this.setState({ drawerWidth: newWidth });
}
};
const onEnd = () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onEnd);
window.removeEventListener('pointercancel', onEnd);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onEnd);
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
this.setState({ isResizing: false });
if (isVfs) {
vfsAutomat.actions.setVfsWidth(currentVfsWidth);
} else {
userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth);
}
};
window.addEventListener('pointermove', onMove, { passive: false });
@@ -199,6 +298,7 @@ export default class ChatDrawer extends PureComponent {
document.body.style.userSelect = '';
};
handleSelectConversation = (e) => {
const id = Number(e.target.value);
if (id) {
@@ -427,6 +527,7 @@ export default class ChatDrawer extends PureComponent {
modelslugflag,
modelreasoning,
modelslug,
isVfsOpen,
} = this.state;
return (
@@ -695,11 +796,36 @@ export default class ChatDrawer extends PureComponent {
</Box>
)}
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen" sx={{ ml: 1, flexShrink: 0 }}>
<CloseRoundedIcon fontSize="small" />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 1, flexShrink: 0 }}>
<Tooltip title={isVfsOpen ? 'VFS-Dateisystem schließen' : 'VFS-Dateisystem parallel öffnen'}>
<IconButton
size="small"
onClick={() => vfsAutomat.actions.toggleVfs()}
aria-label="VFS Dateisystem"
sx={{
p: 0.5,
color: isVfsOpen ? 'primary.main' : 'text.secondary',
bgcolor: isVfsOpen ? 'action.selected' : 'transparent',
borderRadius: 1.5,
border: '1px solid',
borderColor: isVfsOpen ? 'primary.main' : 'transparent',
'&:hover': {
color: 'primary.main',
bgcolor: 'action.hover',
},
}}
>
<FolderOpenRoundedIcon sx={{ fontSize: 19 }} />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen" sx={{ p: 0.5 }}>
<CloseRoundedIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{/* Conversation Selector Bar */}
{conversations.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -1095,7 +1221,8 @@ export default class ChatDrawer extends PureComponent {
}
render() {
const { isOpen, drawerWidth, isResizing } = this.state;
const { isOpen, drawerWidth, isResizing, isVfsOpen, vfsWidth } = this.state;
const totalWidth = isVfsOpen ? drawerWidth + vfsWidth : drawerWidth;
return (
<>
@@ -1106,10 +1233,10 @@ export default class ChatDrawer extends PureComponent {
sx={(theme) => ({
zIndex: theme.zIndex.drawer + 2,
'& .MuiDrawer-paper': {
width: { xs: '100vw', sm: `${drawerWidth}px` },
width: { xs: '100vw', sm: `${totalWidth}px` },
maxWidth: '100vw',
display: 'flex',
flexDirection: 'column',
flexDirection: 'row',
boxShadow: SHADOW_DRAWER,
position: 'fixed',
overflow: 'visible',
@@ -1122,10 +1249,10 @@ export default class ChatDrawer extends PureComponent {
},
paper: {
sx: {
width: { xs: '100vw', sm: `${drawerWidth}px` },
width: { xs: '100vw', sm: `${totalWidth}px` },
maxWidth: '100vw',
display: 'flex',
flexDirection: 'column',
flexDirection: 'row',
boxShadow: SHADOW_DRAWER,
position: 'fixed',
overflow: 'visible',
@@ -1177,11 +1304,79 @@ export default class ChatDrawer extends PureComponent {
/>
</Box>
{this.renderHeader()}
{this.renderBody()}
{this.renderFooter()}
{/* Left: VFS Filesystem Editor Panel (parallel to chat) */}
{isVfsOpen && (
<Box
sx={{
width: { xs: '100%', sm: `${vfsWidth}px` },
minWidth: { sm: 340 },
maxWidth: { sm: 'calc(100vw - 320px)' },
height: '100%',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<VfsEditorPanel onInsertToChat={this.handleInsertToChat} />
</Box>
)}
{/* Vertical Split Resizer between VFS and Chat */}
{isVfsOpen && (
<Box
onPointerDown={this.handleVfsResizerStart}
onMouseDown={this.handleVfsResizerStart}
title="Ziehen zum Anpassen der Spaltenbreiten"
sx={{
width: 8,
cursor: 'col-resize',
bgcolor: 'divider',
flexShrink: 0,
display: { xs: 'none', sm: 'flex' },
alignItems: 'center',
justifyContent: 'center',
touchAction: 'none',
userSelect: 'none',
transition: 'background-color 0.15s, box-shadow 0.15s',
'&:hover, &:active': {
bgcolor: 'primary.main',
boxShadow: `0 0 8px ${BLUE_GLOW}`,
},
}}
>
<Box
sx={{
width: 2,
height: 36,
borderRadius: 1,
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.25)',
}}
/>
</Box>
)}
{/* Right: Chat Panel */}
<Box
sx={{
width: isVfsOpen ? { xs: '100%', sm: `${drawerWidth}px` } : '100%',
flexGrow: 1,
minWidth: { sm: 320 },
display: isVfsOpen ? { xs: 'none', sm: 'flex' } : 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
bgcolor: 'background.paper',
}}
>
{this.renderHeader()}
{this.renderBody()}
{this.renderFooter()}
</Box>
</Drawer>
</>
);
}
}

View File

@@ -18,7 +18,6 @@ import { thumb } from '../../api';
import {
GREEN,
GREEN_BRIGHT,
ICE,
INK_STRONG,
SCRIM_LABEL,
SCRIM_TILE,
@@ -409,69 +408,92 @@ export class ArticleTile extends Tile {
textShadow: TEXT_GLOW_DARK,
}}
>
{article?.brand?.name ? (
<BrandLabel
brand={article.brand}
name={article.brand.name}
size={14}
spacing={0.5}
typographyProps={{
variant: 'caption',
noWrap: true,
sx: {
color: ICE,
opacity: 1,
fontWeight: 500,
lineHeight: 1.1,
textShadow: TEXT_GLOW_DARK,
},
}}
sx={{ mb: 0.25 }}
/>
) : null}
{label ? (
<Typography
variant="body2"
fontWeight={600}
noWrap
sx={{
color: INK_STRONG,
lineHeight: 1.2,
textShadow: TEXT_GLOW_LIGHT,
}}
>
{label}
</Typography>
) : null}
{sku ? (
<Typography
variant="caption"
noWrap
sx={{
color: WHITE,
textShadow: TEXT_GLOW_DARK,
}}
>
{sku}
</Typography>
) : null}
{grossPrice != null && (
<Typography
variant="caption"
fontWeight={700}
component="div"
sx={{
color: GREEN_BRIGHT,
fontWeight: 700,
fontSize: '0.8rem',
lineHeight: 1.2,
mt: 0.25,
textShadow: TEXT_GLOW_DARK,
}}
>
{formatPrice(grossPrice)}
</Typography>
)}
{/* Row 1: Brand */}
<Box
sx={{
height: 18,
minHeight: 18,
display: 'flex',
alignItems: 'center',
mb: 0.25,
}}
>
{article?.brand?.name ? (
<BrandLabel
brand={article.brand}
name={article.brand.name}
size={14}
spacing={0.5}
typographyProps={{
variant: 'caption',
noWrap: true,
sx: {
color: INK_STRONG,
fontWeight: 600,
lineHeight: 1.2,
py: '2px',
textShadow: TEXT_GLOW_LIGHT,
},
}}
/>
) : null}
</Box>
{/* Rows 2 & 3: Name */}
<Typography
variant="body2"
fontWeight={600}
sx={{
color: INK_STRONG,
lineHeight: 1.2,
height: '2.4em',
textShadow: TEXT_GLOW_LIGHT,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
}}
>
{label || article?.name?.trim() || '\u00A0'}
</Typography>
{/* Row 4: SKU */}
<Typography
variant="caption"
noWrap
component="div"
sx={{
color: WHITE,
textShadow: TEXT_GLOW_DARK,
lineHeight: 1.2,
height: '1.2em',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{sku || '\u00A0'}
</Typography>
{/* Row 5: Price */}
<Typography
variant="caption"
component="div"
sx={{
color: GREEN_BRIGHT,
fontWeight: 700,
fontSize: '0.8rem',
lineHeight: 1.2,
height: '1.2em',
mt: 0.25,
textShadow: TEXT_GLOW_DARK,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{grossPrice != null ? formatPrice(grossPrice) : '\u00A0'}
</Typography>
</Box>
</ButtonBase>
);

View File

@@ -0,0 +1,194 @@
import React, { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import vfsAutomat from '../../automats/vfsAutomat';
import VfsFileExplorer from './VfsFileExplorer';
import VfsTextEditor from './VfsTextEditor';
import VfsFilePreview from './VfsFilePreview';
import VfsNewFileDialog from './VfsNewFileDialog';
export default class VfsEditorPanel extends PureComponent {
constructor(props) {
super(props);
const s = vfsAutomat.state;
this.state = {
files: s.files,
loadingFiles: s.loadingFiles,
loadError: s.loadError,
selectedFileName: s.selectedFileName,
selectedFile: s.selectedFile,
fileContent: s.fileContent,
originalContent: s.originalContent,
isDirty: s.isDirty,
loadingContent: s.loadingContent,
isSaving: s.isSaving,
saveError: s.saveError,
isUploading: s.isUploading,
searchQuery: s.searchQuery,
newFileDialogOpen: s.newFileDialogOpen,
};
}
componentDidMount() {
this.unsub = vfsAutomat.subscribe(this, (state) => ({
files: state.files,
loadingFiles: state.loadingFiles,
loadError: state.loadError,
selectedFileName: state.selectedFileName,
selectedFile: state.selectedFile,
fileContent: state.fileContent,
originalContent: state.originalContent,
isDirty: state.isDirty,
loadingContent: state.loadingContent,
isSaving: state.isSaving,
saveError: state.saveError,
isUploading: state.isUploading,
searchQuery: state.searchQuery,
newFileDialogOpen: state.newFileDialogOpen,
}));
if (this.state.files.length === 0 && !this.state.loadingFiles) {
void vfsAutomat.actions.loadFiles();
}
}
componentWillUnmount() {
this.unsub?.();
}
handleMentionInChat = (file) => {
if (!file) return;
if (this.props.onInsertToChat) {
const isImg = file.isImage;
const tag = isImg
? `![${file.name}](${file.name})`
: `[${file.name}](${file.name})`;
this.props.onInsertToChat(tag);
}
};
render() {
const {
files,
loadingFiles,
selectedFileName,
selectedFile,
fileContent,
originalContent,
isDirty,
loadingContent,
isSaving,
saveError,
searchQuery,
newFileDialogOpen,
} = this.state;
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
overflow: 'hidden',
bgcolor: 'background.paper',
borderRight: 1,
borderColor: 'divider',
}}
>
{selectedFileName && selectedFile?.isText ? (
/* Single Column Mode: Text Editor */
<VfsTextEditor
fileName={selectedFileName}
file={selectedFile}
content={fileContent}
originalContent={originalContent}
isDirty={isDirty}
isSaving={isSaving}
saveError={saveError}
loadingContent={loadingContent}
onChange={(c) => vfsAutomat.actions.updateContent(c)}
onSave={() => vfsAutomat.actions.saveCurrentFile()}
onClose={() => vfsAutomat.actions.selectFile(null)}
onDownload={() => vfsAutomat.actions.downloadFile(selectedFileName)}
onMentionInChat={() => this.handleMentionInChat(selectedFile)}
/>
) : selectedFileName && !selectedFile?.isText ? (
/* Single Column Mode: Preview (Image, PDF, Binary) */
<VfsFilePreview
fileName={selectedFileName}
file={selectedFile}
onClose={() => vfsAutomat.actions.selectFile(null)}
onDownload={() => vfsAutomat.actions.downloadFile(selectedFileName)}
onMentionInChat={() => this.handleMentionInChat(selectedFile)}
/>
) : (
/* Single Column Mode: File Explorer */
<>
<Box
sx={{
p: 1.5,
px: 2,
borderBottom: 1,
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: 'background.paper',
minHeight: 52,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FolderOpenRoundedIcon color="primary" sx={{ fontSize: 22 }} />
<Typography variant="subtitle1" fontWeight={700}>
VFS Dateisystem
</Typography>
</Box>
<Tooltip title="VFS-Dateisystem schließen">
<IconButton
size="small"
onClick={() => vfsAutomat.actions.closeVfs()}
aria-label="VFS schließen"
>
<CloseRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ flexGrow: 1, minHeight: 0, overflow: 'hidden' }}>
<VfsFileExplorer
files={files}
loading={loadingFiles}
selectedFileName={selectedFileName}
searchQuery={searchQuery}
onSelectFile={(name) => vfsAutomat.actions.selectFile(name)}
onRefresh={() => vfsAutomat.actions.loadFiles()}
onOpenNewDialog={() => vfsAutomat.actions.setNewFileDialogOpen(true)}
onSearchChange={(q) => vfsAutomat.actions.setSearchQuery(q)}
onDownload={(name) => vfsAutomat.actions.downloadFile(name)}
onDelete={(name) => vfsAutomat.actions.deleteFile(name)}
onUpload={(filesToUpload) => vfsAutomat.actions.uploadFiles(filesToUpload)}
onMentionInChat={this.handleMentionInChat}
/>
</Box>
</>
)}
{/* New File Dialog */}
<VfsNewFileDialog
open={newFileDialogOpen}
onClose={() => vfsAutomat.actions.setNewFileDialogOpen(false)}
onCreate={(name) => vfsAutomat.actions.createFile(name)}
isCreating={isSaving}
/>
</Box>
);
}
}

View File

@@ -0,0 +1,735 @@
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 InputBase from '@mui/material/InputBase';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import Chip from '@mui/material/Chip';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import ClearRoundedIcon from '@mui/icons-material/ClearRounded';
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
import NoteAddRoundedIcon from '@mui/icons-material/NoteAddRounded';
import CloudUploadRoundedIcon from '@mui/icons-material/CloudUploadRounded';
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded';
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded';
import FolderRoundedIcon from '@mui/icons-material/FolderRounded';
import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded';
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded';
import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded';
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import VfsFileIcon, { getCategoryBadge } from './VfsFileIcon';
import { formatVfsSize, formatVfsDate } from './vfsUtils';
/**
* Returns all ancestor directory paths for a file or folder path.
* e.g. "docs/api/v1/spec.json" -> ["docs", "docs/api", "docs/api/v1"]
*/
function getParentFolderPaths(filePath) {
if (!filePath) return [];
const clean = String(filePath).replace(/^\/+/, '').replace(/\/+$/, '');
const parts = clean.split('/').filter(Boolean);
const paths = [];
for (let i = 0; i < parts.length - 1; i++) {
paths.push(parts.slice(0, i + 1).join('/'));
}
return paths;
}
/**
* Builds a hierarchical tree node structure from a flat list of VfsFiles.
* In every node, folders and files are separated and can be rendered recursively.
*/
function buildFileTree(files, searchQuery = '') {
const q = searchQuery ? searchQuery.trim().toLowerCase() : '';
const filtered = (files || []).filter((f) => {
if (!q) return true;
return f.name.toLowerCase().includes(q) || (f.mime && f.mime.toLowerCase().includes(q));
});
const root = {
name: '',
path: '',
isDirectory: true,
folders: new Map(),
files: [],
};
for (const file of filtered) {
const cleanPath = String(file.name || '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!cleanPath) continue;
const parts = cleanPath.split('/').filter(Boolean);
let current = root;
for (let i = 0; i < parts.length - 1; i++) {
const folderName = parts[i];
const folderPath = parts.slice(0, i + 1).join('/');
if (!current.folders.has(folderName)) {
current.folders.set(folderName, {
name: folderName,
path: folderPath,
isDirectory: true,
folders: new Map(),
files: [],
});
}
current = current.folders.get(folderName);
}
const fileName = parts[parts.length - 1];
current.files.push({
name: fileName,
path: cleanPath,
isDirectory: false,
fileData: file,
});
}
// Calculate recursive file counts
function countFiles(node) {
let count = node.files.length;
for (const folder of node.folders.values()) {
count += countFiles(folder);
}
node.totalFileCount = count;
return count;
}
countFiles(root);
return { root, totalCount: filtered.length };
}
export default class VfsFileExplorer extends PureComponent {
constructor(props) {
super(props);
const initialExpanded = new Set();
if (props.selectedFileName) {
for (const p of getParentFolderPaths(props.selectedFileName)) {
initialExpanded.add(p);
}
}
this.state = {
menuAnchor: null,
menuFile: null,
deleteConfirmFile: null,
isDraggingOver: false,
expandedFolders: initialExpanded,
};
this.fileInputRef = React.createRef();
}
componentDidUpdate(prevProps) {
if (this.props.selectedFileName && this.props.selectedFileName !== prevProps.selectedFileName) {
const parents = getParentFolderPaths(this.props.selectedFileName);
if (parents.length > 0) {
this.setState((prev) => {
const next = new Set(prev.expandedFolders);
let changed = false;
for (const p of parents) {
if (!next.has(p)) {
next.add(p);
changed = true;
}
}
return changed ? { expandedFolders: next } : null;
});
}
}
}
toggleFolder = (folderPath) => {
this.setState((prev) => {
const next = new Set(prev.expandedFolders);
if (next.has(folderPath)) {
next.delete(folderPath);
} else {
next.add(folderPath);
}
return { expandedFolders: next };
});
};
handleOpenMenu = (e, file) => {
e.stopPropagation();
e.preventDefault();
this.setState({ menuAnchor: e.currentTarget, menuFile: file });
};
handleCloseMenu = () => {
this.setState({ menuAnchor: null, menuFile: null });
};
handleTriggerUpload = () => {
this.fileInputRef.current?.click();
};
handleFileInputChange = (e) => {
const files = e.target?.files;
if (files && files.length > 0) {
this.props.onUpload(Array.from(files));
}
if (e.target) e.target.value = '';
};
handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer?.types?.includes('Files')) {
this.setState({ isDraggingOver: true });
}
};
handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
if (!this.state.isDraggingOver && e.dataTransfer?.types?.includes('Files')) {
this.setState({ isDraggingOver: true });
}
};
handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget.contains(e.relatedTarget)) return;
this.setState({ isDraggingOver: false });
};
handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
this.setState({ isDraggingOver: false });
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
this.props.onUpload(Array.from(files));
}
};
handleCopyPath = (file) => {
if (!file) return;
navigator.clipboard?.writeText?.(file.name);
this.handleCloseMenu();
};
handleMentionInChat = (file) => {
if (!file) return;
if (this.props.onMentionInChat) {
this.props.onMentionInChat(file);
}
this.handleCloseMenu();
};
renderFolderNode = (folder, depth) => {
const { searchQuery } = this.props;
const isSearching = Boolean(searchQuery && searchQuery.trim());
const isExpanded = isSearching || this.state.expandedFolders.has(folder.path);
const subfolders = Array.from(folder.folders.values()).sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
);
const files = [...folder.files].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
);
return (
<Box key={folder.path} sx={{ display: 'flex', flexDirection: 'column' }}>
{/* Folder Header Row */}
<Box
onClick={() => this.toggleFolder(folder.path)}
sx={{
display: 'flex',
alignItems: 'center',
py: 0.5,
px: 1,
pl: `${depth * 14 + 6}px`,
borderRadius: 1.5,
cursor: 'pointer',
userSelect: 'none',
transition: 'background-color 0.1s',
'&:hover': {
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
},
}}
>
{/* Chevron */}
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 18,
height: 18,
mr: 0.4,
color: 'text.secondary',
flexShrink: 0,
}}
>
{isExpanded ? (
<KeyboardArrowDownRoundedIcon sx={{ fontSize: 17 }} />
) : (
<KeyboardArrowRightRoundedIcon sx={{ fontSize: 17 }} />
)}
</Box>
{/* Folder Icon */}
{isExpanded ? (
<FolderOpenRoundedIcon
sx={{ fontSize: 18, color: 'primary.main', mr: 0.8, flexShrink: 0 }}
/>
) : (
<FolderRoundedIcon
sx={{ fontSize: 18, opacity: 0.75, color: 'text.secondary', mr: 0.8, flexShrink: 0 }}
/>
)}
{/* Folder Name */}
<Typography
variant="body2"
noWrap
fontWeight={600}
sx={{ fontSize: '0.82rem', fontFamily: 'monospace', color: 'text.primary', flexGrow: 1 }}
>
{folder.name}
</Typography>
{/* Total Files Count in Folder */}
<Typography
variant="caption"
sx={{
fontSize: '0.7rem',
color: 'text.disabled',
ml: 1,
flexShrink: 0,
}}
>
{folder.totalFileCount}
</Typography>
</Box>
{/* Folder Children (Directories FIRST, then files) */}
{isExpanded && (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{/* 1. Subdirectories FIRST */}
{subfolders.map((sub) => this.renderFolderNode(sub, depth + 1))}
{/* 2. Files NEXT */}
{files.map((file) => this.renderFileNode(file, depth + 1))}
</Box>
)}
</Box>
);
};
renderFileNode = (file, depth) => {
const { selectedFileName, onSelectFile } = this.props;
const isSelected = file.path === selectedFileName;
const badge = getCategoryBadge(file.path, file.fileData.mime);
return (
<Box
key={file.path}
onClick={() => onSelectFile(file.path)}
sx={(theme) => ({
display: 'flex',
alignItems: 'center',
py: 0.5,
px: 1,
pl: `${depth * 14 + 26}px`,
borderRadius: 1.5,
cursor: 'pointer',
userSelect: 'none',
bgcolor: isSelected
? theme.palette.mode === 'dark'
? 'primary.dark'
: 'action.selected'
: 'transparent',
border: '1px solid',
borderColor: isSelected ? 'primary.main' : 'transparent',
transition: 'background-color 0.1s, border-color 0.1s',
'&:hover': {
bgcolor: isSelected
? theme.palette.mode === 'dark'
? 'primary.dark'
: 'action.selected'
: 'action.hover',
},
})}
>
<VfsFileIcon
fileName={file.path}
mime={file.fileData.mime}
sx={{ mr: 1, flexShrink: 0, fontSize: 18 }}
/>
<Box sx={{ minWidth: 0, flexGrow: 1, pr: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Typography
variant="body2"
fontWeight={isSelected ? 600 : 500}
noWrap
sx={{ fontFamily: 'monospace', fontSize: '0.82rem' }}
>
{file.name}
</Typography>
<Chip
label={badge.label}
size="small"
sx={{
height: 17,
fontSize: '0.62rem',
fontWeight: 600,
borderRadius: 1,
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
color: badge.color,
}}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mt: 0.15 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{formatVfsSize(file.fileData.size)}
</Typography>
{file.fileData.updated_at && (
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.68rem' }}>
{formatVfsDate(file.fileData.updated_at)}
</Typography>
)}
</Box>
</Box>
{/* Quick action buttons on hover / select */}
<Box
onClick={(e) => e.stopPropagation()}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.2,
opacity: isSelected ? 1 : 0.7,
'&:hover': { opacity: 1 },
}}
>
<Tooltip title="Herunterladen">
<IconButton
size="small"
onClick={() => this.props.onDownload(file.path)}
sx={{ p: 0.5 }}
>
<DownloadRoundedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={(e) => this.handleOpenMenu(e, file.fileData)}
sx={{ p: 0.5 }}
>
<MoreVertRoundedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
);
};
renderTree() {
const { files, searchQuery } = this.props;
const { root, totalCount } = buildFileTree(files, searchQuery);
if (totalCount === 0) {
return (
<Box
sx={{
py: 6,
px: 2,
textAlign: 'center',
color: 'text.secondary',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<FolderRoundedIcon sx={{ fontSize: 44, opacity: 0.3 }} />
<Typography variant="body2">
{searchQuery ? `Keine Dateien für "${searchQuery}" gefunden` : 'Keine Dateien im VFS vorhanden'}
</Typography>
{!searchQuery && (
<Typography variant="caption" sx={{ maxWidth: 220 }}>
Klicke oben auf Upload oder erstelle eine neue Datei.
</Typography>
)}
</Box>
);
}
const rootFolders = Array.from(root.folders.values()).sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
);
const rootFiles = [...root.files].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{/* Top-level directories FIRST */}
{rootFolders.map((folder) => this.renderFolderNode(folder, 0))}
{/* Top-level files NEXT */}
{rootFiles.map((file) => this.renderFileNode(file, 0))}
</Box>
);
}
render() {
const {
loading,
onRefresh,
onOpenNewDialog,
searchQuery,
onSearchChange,
files,
} = this.props;
const { menuAnchor, menuFile, isDraggingOver } = this.state;
const isText = menuFile?.isText;
return (
<Box
onDragEnter={this.handleDragEnter}
onDragOver={this.handleDragOver}
onDragLeave={this.handleDragLeave}
onDrop={this.handleDrop}
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
position: 'relative',
bgcolor: isDraggingOver ? 'action.selected' : 'inherit',
transition: 'background-color 0.15s',
}}
>
{/* Hidden Upload Input */}
<input
ref={this.fileInputRef}
type="file"
multiple
onChange={this.handleFileInputChange}
style={{ display: 'none' }}
/>
{/* Drag & Drop Overlay */}
{isDraggingOver && (
<Box
sx={{
position: 'absolute',
inset: 8,
border: '2px dashed',
borderColor: 'primary.main',
borderRadius: 3,
bgcolor: 'background.paper',
opacity: 0.94,
zIndex: 10,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
pointerEvents: 'none',
}}
>
<CloudUploadRoundedIcon color="primary" sx={{ fontSize: 44 }} />
<Typography variant="subtitle1" fontWeight={700} color="primary.main">
Dateien im VFS ablegen
</Typography>
<Typography variant="caption" color="text.secondary">
Die Dateien werden sofort auf den Server hochgeladen
</Typography>
</Box>
)}
{/* Explorer Header / Actions */}
<Box
sx={{
p: 1.5,
borderBottom: 1,
borderColor: 'divider',
display: 'flex',
flexDirection: 'column',
gap: 1.25,
flexShrink: 0,
}}
>
{/* Top Bar with Action Buttons */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
{loading && <CircularProgress size={16} thickness={4} />}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 'auto' }}>
<Tooltip title="Aktualisieren">
<span>
<IconButton size="small" onClick={onRefresh} disabled={loading} sx={{ p: 0.6 }}>
<RefreshRoundedIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Datei(en) hochladen">
<Button
size="small"
variant="outlined"
startIcon={<CloudUploadRoundedIcon />}
onClick={this.handleTriggerUpload}
sx={{ borderRadius: 2, py: 0.3, px: 1, fontSize: '0.75rem' }}
>
Upload
</Button>
</Tooltip>
<Tooltip title="Neue Datei anlegen">
<Button
size="small"
variant="contained"
startIcon={<NoteAddRoundedIcon />}
onClick={onOpenNewDialog}
sx={{ borderRadius: 2, py: 0.3, px: 1, fontSize: '0.75rem' }}
>
Neu
</Button>
</Tooltip>
</Box>
</Box>
{/* Search Filter Bar */}
<Box
sx={(theme) => ({
display: 'flex',
alignItems: 'center',
px: 1,
py: 0.25,
borderRadius: 2,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
border: '1px solid transparent',
'&:focus-within': {
borderColor: 'primary.main',
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.09)' : '#fff',
},
})}
>
<SearchRoundedIcon sx={{ fontSize: 18, color: 'text.secondary', mr: 0.75 }} />
<InputBase
fullWidth
placeholder="VFS durchsuchen…"
value={searchQuery || ''}
onChange={(e) => onSearchChange(e.target.value)}
sx={{ fontSize: '0.82rem' }}
/>
{searchQuery && (
<IconButton size="small" onClick={() => onSearchChange('')} sx={{ p: 0.2 }}>
<ClearRoundedIcon sx={{ fontSize: 16 }} />
</IconButton>
)}
</Box>
</Box>
{/* Scrollable File List */}
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 1.5 }}>
{this.renderTree()}
</Box>
{/* Context Menu */}
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={this.handleCloseMenu}
PaperProps={{ sx: { borderRadius: 2, minWidth: 180 } }}
>
{isText && (
<MenuItem
onClick={() => {
if (menuFile) this.props.onSelectFile(menuFile.name);
this.handleCloseMenu();
}}
>
<ListItemIcon>
<EditRoundedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Im Editor bearbeiten" />
</MenuItem>
)}
{!isText && (
<MenuItem
onClick={() => {
if (menuFile) this.props.onSelectFile(menuFile.name);
this.handleCloseMenu();
}}
>
<ListItemIcon>
<VisibilityRoundedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Vorschau" />
</MenuItem>
)}
<MenuItem
onClick={() => {
if (menuFile) this.props.onDownload(menuFile.name);
this.handleCloseMenu();
}}
>
<ListItemIcon>
<DownloadRoundedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Herunterladen" />
</MenuItem>
<MenuItem onClick={() => this.handleCopyPath(menuFile)}>
<ListItemIcon>
<ContentCopyRoundedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Pfad kopieren" />
</MenuItem>
{this.props.onMentionInChat && (
<MenuItem onClick={() => this.handleMentionInChat(menuFile)}>
<ListItemIcon>
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText primary="In Chat erwähnen" />
</MenuItem>
)}
<MenuItem
onClick={() => {
if (menuFile && window.confirm(`Möchtest du "${menuFile.name}" wirklich aus dem VFS löschen?`)) {
this.props.onDelete(menuFile.name);
}
this.handleCloseMenu();
}}
sx={{ color: 'error.main' }}
>
<ListItemIcon sx={{ color: 'error.main' }}>
<DeleteOutlineRoundedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Datei löschen" />
</MenuItem>
</Menu>
</Box>
);
}
}

View File

@@ -0,0 +1,80 @@
import React from 'react';
import Box from '@mui/material/Box';
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
import ImageRoundedIcon from '@mui/icons-material/ImageRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
import ArticleRoundedIcon from '@mui/icons-material/ArticleRounded';
import { getVfsCategory } from './vfsUtils';
const CATEGORY_CONFIG = {
skill: {
icon: AutoAwesomeRoundedIcon,
color: '#9c27b0', // Purple
label: 'Skill',
},
code: {
icon: CodeRoundedIcon,
color: '#e65100', // Deep Orange
label: 'Code',
},
table: {
icon: TableChartRoundedIcon,
color: '#2e7d32', // Green
label: 'Tabelle',
},
pdf: {
icon: PictureAsPdfRoundedIcon,
color: '#d32f2f', // Red
label: 'PDF',
},
image: {
icon: ImageRoundedIcon,
color: '#0288d1', // Light Blue / Cyan
label: 'Bild',
},
markdown: {
icon: ArticleRoundedIcon,
color: '#1976d2', // Blue
label: 'Markdown',
},
text: {
icon: DescriptionRoundedIcon,
color: '#546e7a', // Blue Grey
label: 'Text',
},
binary: {
icon: InsertDriveFileRoundedIcon,
color: '#757575', // Grey
label: 'Datei',
},
};
export default function VfsFileIcon({ fileName, mime, sx = {}, fontSize = 'small' }) {
const category = getVfsCategory(fileName, mime);
const cfg = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.binary;
const IconComponent = cfg.icon;
return (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: cfg.color,
...sx,
}}
>
<IconComponent fontSize={fontSize} sx={{ color: 'inherit' }} />
</Box>
);
}
export function getCategoryBadge(fileName, mime) {
const category = getVfsCategory(fileName, mime);
return CATEGORY_CONFIG[category] || CATEGORY_CONFIG.binary;
}

View File

@@ -0,0 +1,232 @@
import React, { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Chip from '@mui/material/Chip';
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import VfsFileIcon, { getCategoryBadge } from './VfsFileIcon';
import { formatVfsSize, formatVfsDate } from './vfsUtils';
export default class VfsFilePreview extends PureComponent {
renderImagePreview(url) {
return (
<Box
sx={{
flexGrow: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 3,
overflow: 'auto',
backgroundImage:
'linear-gradient(45deg, rgba(0,0,0,0.06) 25%, transparent 25%), linear-gradient(-45deg, rgba(0,0,0,0.06) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.06) 75%), linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.06) 75%)',
backgroundSize: '20px 20px',
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
}}
>
<Box
component="img"
src={url}
alt=""
sx={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: 2,
boxShadow: 2,
bgcolor: 'background.paper',
}}
/>
</Box>
);
}
renderPdfPreview(url, fileName) {
return (
<Box
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 4,
gap: 2,
textAlign: 'center',
}}
>
<VfsFileIcon fileName={fileName} fontSize="large" sx={{ transform: 'scale(1.8)', mb: 1 }} />
<Typography variant="h6" fontWeight={700}>
PDF-Dokument
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 360 }}>
Klicke unten, um das Dokument in einem neuen Tab zu öffnen oder herunterzuladen.
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, mt: 1 }}>
<Button
variant="contained"
startIcon={<OpenInNewRoundedIcon />}
href={url}
target="_blank"
rel="noopener noreferrer"
sx={{ borderRadius: 2 }}
>
Im Browser öffnen
</Button>
<Button
variant="outlined"
startIcon={<DownloadRoundedIcon />}
onClick={this.props.onDownload}
sx={{ borderRadius: 2 }}
>
Herunterladen
</Button>
</Box>
</Box>
);
}
renderBinaryPreview(fileName, file) {
return (
<Box
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 4,
gap: 2,
textAlign: 'center',
}}
>
<VfsFileIcon fileName={fileName} fontSize="large" sx={{ transform: 'scale(1.8)', mb: 1 }} />
<Typography variant="h6" fontWeight={700}>
Binärdatei
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 360 }}>
Dieser Dateityp kann nicht direkt im Browser bearbeitet werden.
</Typography>
<Button
variant="contained"
startIcon={<DownloadRoundedIcon />}
onClick={this.props.onDownload}
sx={{ borderRadius: 2, mt: 1 }}
>
Datei herunterladen ({formatVfsSize(file?.size)})
</Button>
</Box>
);
}
render() {
const { fileName, file, onClose, onDownload, onMentionInChat } = this.props;
const badge = getCategoryBadge(fileName, file?.mime);
const vfsUrl = `/api/vfs/${encodeURIComponent(fileName).replace(/%2F/g, '/')}?raw=1`;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Preview Header */}
<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,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0, mr: 1 }}>
<Tooltip title="Zurück zur Dateiliste">
<IconButton size="small" onClick={onClose} sx={{ p: 0.5, mr: 0.25 }}>
<ArrowBackRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
<VfsFileIcon fileName={fileName} mime={file?.mime} fontSize="small" />
<Typography
variant="body2"
fontWeight={700}
noWrap
sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
>
{fileName}
</Typography>
<Chip
label={badge.label}
size="small"
sx={{
height: 20,
fontSize: '0.68rem',
fontWeight: 600,
borderRadius: 1,
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
color: badge.color,
}}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
{onMentionInChat && (
<Tooltip title="In Chat erwähnen">
<IconButton size="small" onClick={onMentionInChat} sx={{ p: 0.6 }}>
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Herunterladen">
<IconButton size="small" onClick={onDownload} sx={{ p: 0.6 }}>
<DownloadRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Vorschau schließen">
<IconButton size="small" onClick={onClose} sx={{ p: 0.6, ml: 0.5 }}>
<CloseRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
{/* Preview Content */}
{file?.isImage && this.renderImagePreview(vfsUrl)}
{file?.isPdf && this.renderPdfPreview(vfsUrl, fileName)}
{!file?.isImage && !file?.isPdf && this.renderBinaryPreview(fileName, file)}
{/* Footer info bar */}
<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,
}}
>
<span>{formatVfsSize(file?.size)}</span>
<span>{file?.mime || 'application/octet-stream'}</span>
{file?.updated_at && <span>{formatVfsDate(file.updated_at)}</span>}
</Box>
</Box>
);
}
}

View File

@@ -0,0 +1,151 @@
import React, { PureComponent } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Typography from '@mui/material/Typography';
import NoteAddRoundedIcon from '@mui/icons-material/NoteAddRounded';
const COMMON_EXTENSIONS = [
{ ext: '.txt', label: 'Text (.txt)' },
{ ext: '.md', label: 'Markdown (.md)' },
{ ext: '.csv', label: 'CSV (.csv)' },
{ ext: '.json', label: 'JSON (.json)' },
{ ext: '.js', label: 'JavaScript (.js)' },
{ ext: '.html', label: 'HTML (.html)' },
{ ext: '.SKILL', label: 'Agent Skill (.SKILL)' },
];
export default class VfsNewFileDialog extends PureComponent {
constructor(props) {
super(props);
this.state = {
filename: '',
error: '',
};
this.inputRef = React.createRef();
}
componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) {
this.setState({ filename: '', error: '' }, () => {
setTimeout(() => this.inputRef.current?.focus(), 80);
});
}
}
handleExtensionClick = (ext) => {
let name = this.state.filename.trim();
if (!name) {
name = `neue_datei${ext}`;
} else {
// If it already has an extension, replace it
const lastDot = name.lastIndexOf('.');
if (lastDot > 0) {
name = name.slice(0, lastDot) + ext;
} else {
name = name + ext;
}
}
this.setState({ filename: name, error: '' });
};
handleSubmit = (e) => {
e?.preventDefault();
const clean = this.state.filename.trim().replace(/^[\/\\]+/, '');
if (!clean) {
this.setState({ error: 'Bitte gib einen Dateinamen ein.' });
return;
}
if (clean.includes('..') || clean.includes('\\')) {
this.setState({ error: 'Ungültige Zeichen im Dateinamen.' });
return;
}
this.props.onCreate(clean);
};
render() {
const { open, onClose, isCreating } = this.props;
const { filename, error } = this.state;
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="xs"
fullWidth
PaperProps={{
sx: { borderRadius: 3, p: 1 },
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.2, pb: 1 }}>
<NoteAddRoundedIcon color="primary" />
<Typography variant="h6" fontWeight={700}>
Neue VFS-Datei erstellen
</Typography>
</DialogTitle>
<form onSubmit={this.handleSubmit}>
<DialogContent sx={{ pt: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Dateien werden im virtuellen Dateisystem des aktuellen Mandanten gespeichert.
Verwende ggf. Unterordner wie <code>scripts/test.js</code>.
</Typography>
<TextField
inputRef={this.inputRef}
fullWidth
size="small"
label="Dateiname oder Pfad"
placeholder="z.B. rechnung.csv oder docs/info.md"
value={filename}
onChange={(e) => this.setState({ filename: e.target.value, error: '' })}
error={Boolean(error)}
helperText={error}
autoFocus
spellCheck={false}
autoCapitalize="off"
/>
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
Häufige Dateitypen:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{COMMON_EXTENSIONS.map((item) => (
<Chip
key={item.ext}
label={item.ext}
size="small"
variant="outlined"
clickable
onClick={() => this.handleExtensionClick(item.ext)}
sx={{ fontSize: '0.75rem', fontFamily: 'monospace', borderRadius: 1.5 }}
/>
))}
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, pt: 1 }}>
<Button onClick={onClose} disabled={isCreating} color="inherit">
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
disabled={isCreating || !filename.trim()}
sx={{ borderRadius: 2 }}
>
{isCreating ? 'Erstelle…' : 'Erstellen & Öffnen'}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
}

View File

@@ -0,0 +1,386 @@
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 Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import Chip from '@mui/material/Chip';
import SaveRoundedIcon from '@mui/icons-material/SaveRounded';
import UndoRoundedIcon from '@mui/icons-material/UndoRounded';
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import WrapTextRoundedIcon from '@mui/icons-material/WrapTextRounded';
import Alert from '@mui/material/Alert';
import VfsFileIcon from './VfsFileIcon';
import { formatVfsSize } from './vfsUtils';
export default class VfsTextEditor extends PureComponent {
constructor(props) {
super(props);
this.textareaRef = React.createRef();
this.gutterRef = React.createRef();
this.state = {
wrapLines: true,
cursorLine: 1,
cursorCol: 1,
};
}
componentDidMount() {
this.focusEditor();
}
componentDidUpdate(prevProps) {
if (prevProps.fileName !== this.props.fileName) {
this.focusEditor();
}
}
focusEditor = () => {
setTimeout(() => {
this.textareaRef.current?.focus();
}, 50);
};
handleScroll = () => {
if (this.textareaRef.current && this.gutterRef.current) {
this.gutterRef.current.scrollTop = this.textareaRef.current.scrollTop;
}
};
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 });
}
};
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>
);
}
return lines;
}
render() {
const {
fileName,
file,
content,
isDirty,
isSaving,
saveError,
loadingContent,
onSave,
onClose,
onDownload,
onMentionInChat,
} = this.props;
const lineCount = (content || '').split('\n').length;
const charCount = (content || '').length;
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? '#14171a' : '#f8f9fa',
overflow: 'hidden',
}}
>
{/* Editor Top Bar */}
<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 }}>
<Tooltip title="Zurück zur Dateiliste">
<IconButton size="small" onClick={onClose} sx={{ p: 0.5, mr: 0.25 }}>
<ArrowBackRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
<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 }}
/>
) : (
<Chip
label="Gespeichert"
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.68rem', borderRadius: 1, opacity: 0.7 }}
/>
)}
</Box>
{/* Action Buttons */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
{onMentionInChat && (
<Tooltip title="In Chat erwähnen">
<IconButton size="small" onClick={onMentionInChat} sx={{ p: 0.6 }}>
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Herunterladen">
<IconButton size="small" onClick={onDownload} sx={{ p: 0.6 }}>
<DownloadRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
{isDirty && (
<Tooltip title="Änderungen verwerfen">
<IconButton size="small" onClick={this.handleRevert} sx={{ p: 0.6, color: 'text.secondary' }}>
<UndoRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title={this.state.wrapLines ? 'Zeilenumbruch ausschalten' : 'Zeilenumbruch einschalten'}>
<IconButton
size="small"
onClick={this.toggleWrap}
color={this.state.wrapLines ? 'primary' : 'default'}
sx={{
p: 0.6,
bgcolor: this.state.wrapLines ? 'action.selected' : 'transparent',
}}
>
<WrapTextRoundedIcon 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>
<Tooltip title="Editor schließen">
<IconButton size="small" onClick={onClose} sx={{ p: 0.6, ml: 0.5 }}>
<CloseRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
</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 */}
<Box sx={{ flexGrow: 1, display: 'flex', minHeight: 0, position: 'relative' }}>
{loadingContent ? (
<Box
sx={{
flexGrow: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
color: 'text.secondary',
}}
>
<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={(theme) => ({
flexGrow: 1,
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',
},
})}
/>
</>
)}
</Box>
{/* Status Bar */}
<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 {this.state.cursorLine}, Spalte {this.state.cursorCol}
</span>
<span>{lineCount} Zeilen</span>
<span>{charCount} Zeichen</span>
<span>{formatVfsSize(charCount)}</span>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span>UTF-8</span>
<span>{file?.mime || 'text/plain'}</span>
</Box>
</Box>
</Box>
);
}
}

View File

@@ -0,0 +1,6 @@
export { default as VfsEditorPanel } from './VfsEditorPanel';
export { default as VfsFileExplorer } from './VfsFileExplorer';
export { default as VfsTextEditor } from './VfsTextEditor';
export { default as VfsFilePreview } from './VfsFilePreview';
export { default as VfsFileIcon } from './VfsFileIcon';
export * from './vfsUtils';

View File

@@ -0,0 +1,97 @@
/**
* @file vfsUtils.js
* @description Helper functions for VFS file types, formatting, and path manipulation.
*/
/**
* Format bytes to human readable string.
* @param {number} bytes
* @returns {string}
*/
export function formatVfsSize(bytes) {
if (bytes == null || Number.isNaN(bytes) || bytes < 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Format date string into local compact date/time.
* @param {string} isoString
* @returns {string}
*/
export function formatVfsDate(isoString) {
if (!isoString) return '';
try {
const d = new Date(isoString);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString([], {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '';
}
}
/**
* Detect category of file.
* @param {string} filePath
* @param {string} [mime]
* @returns {'code'|'table'|'markdown'|'text'|'pdf'|'image'|'skill'|'binary'}
*/
export function getVfsCategory(filePath, mime = '') {
const p = String(filePath || '').toLowerCase();
const m = String(mime || '').toLowerCase();
if (p.endsWith('.skill')) return 'skill';
if (p.endsWith('.pdf') || m === 'application/pdf') return 'pdf';
if (
m.startsWith('image/') ||
/\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/.test(p)
) {
return 'image';
}
if (
p.endsWith('.csv') ||
p.endsWith('.tsv') ||
p.endsWith('.xlsx') ||
p.endsWith('.xls') ||
m.includes('csv') ||
m.includes('spreadsheet')
) {
return 'table';
}
if (p.endsWith('.md')) return 'markdown';
if (
/\.(js|mjs|cjs|ts|tsx|jsx|json|html|css|scss|xml|sql|py|sh|yaml|yml|env)$/.test(p) ||
m === 'application/json' ||
m === 'application/javascript'
) {
return 'code';
}
if (m.startsWith('text/') || /\.(txt|log|ini|conf)$/.test(p)) {
return 'text';
}
return 'binary';
}
/**
* Split a VFS file path into directory and basename.
* @param {string} path
* @returns {{ dir: string, base: string }}
*/
export function splitVfsPath(path) {
const clean = String(path || '').replace(/^[\/\\]+/, '');
const lastSlash = clean.lastIndexOf('/');
if (lastSlash === -1) {
return { dir: '', base: clean };
}
return {
dir: clean.slice(0, lastSlash),
base: clean.slice(lastSlash + 1),
};
}