diff --git a/src/api/index.js b/src/api/index.js
index 82b9e0b..3d2712e 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -1944,6 +1944,85 @@ export const modelconversation = {
},
};
+export const vfs = {
+ /**
+ * GET /api/vfs
+ * List files in the virtual filesystem for the selected company.
+ * → 200 { files: [{ name, size, mime, updated_at, created_at, isText, isImage, isPdf }] }
+ */
+ list() {
+ return http('/api/vfs');
+ },
+
+ /**
+ * GET /api/vfs/*path
+ * Fetch raw content of a file (e.g. text for the editor).
+ * @param {string} path
+ */
+ async getText(path) {
+ const clean = String(path || '').replace(/^\/+/, '');
+ const res = await fetch(`/api/vfs/${clean}?raw=1`, {
+ credentials: 'include',
+ });
+ if (!res.ok) {
+ throw new Error(`Datei konnte nicht geladen werden (${res.status} ${res.statusText})`);
+ }
+ return res.text();
+ },
+
+ /**
+ * PUT /api/vfs/*path
+ * Create or update a file.
+ * @param {string} path
+ * @param {string} content
+ * @param {string} [mime]
+ */
+ save(path, content, mime) {
+ const clean = String(path || '').replace(/^\/+/, '');
+ return http(`/api/vfs/${clean}`, {
+ method: 'PUT',
+ body: { content, mime },
+ });
+ },
+
+ /**
+ * DELETE /api/vfs/*path
+ * Delete a file.
+ * @param {string} path
+ */
+ delete(path) {
+ const clean = String(path || '').replace(/^\/+/, '');
+ return http(`/api/vfs/${clean}`, {
+ method: 'DELETE',
+ });
+ },
+
+ /**
+ * POST /api/vfs/upload
+ * Upload multiple files or a single file to VFS.
+ * @param {File[]|FileList|File} files
+ * @param {string} [targetDir]
+ */
+ async upload(files, targetDir = '') {
+ const formData = new FormData();
+ if (targetDir) formData.append('targetDir', targetDir);
+ const list = Array.isArray(files) ? files : files instanceof FileList ? Array.from(files) : [files];
+ for (const f of list) {
+ formData.append('files', f);
+ }
+ const res = await fetch('/api/vfs/upload', {
+ method: 'POST',
+ credentials: 'include',
+ body: formData,
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || `Upload fehlgeschlagen (${res.status})`);
+ }
+ return res.json();
+ },
+};
+
export default {
auth,
article,
@@ -1968,8 +2047,10 @@ export default {
supplier,
thumb,
unit,
+ vfs,
warehouse,
stock,
globalHistory,
};
+
diff --git a/src/automats/index.js b/src/automats/index.js
index 3db7c92..6f2b3cb 100644
--- a/src/automats/index.js
+++ b/src/automats/index.js
@@ -156,6 +156,9 @@ export {
selectHasPendingAsk,
} from './chatAutomat';
+export { default as vfsAutomat } from './vfsAutomat';
+
+
/**
* ============================================================================
* Overview of Automats in Dashfront
diff --git a/src/automats/pwaAutomat.js b/src/automats/pwaAutomat.js
index f0fa6a2..13494bd 100644
--- a/src/automats/pwaAutomat.js
+++ b/src/automats/pwaAutomat.js
@@ -97,7 +97,7 @@ export function initPwa() {
onRegistered(registration) {
if (!registration) return;
- // Check for new updates periodically (every 30 minutes)
+ // Check for new updates periodically (every 1 minutes)
setInterval(() => {
registration.update().catch(() => { });
}, 1 * 60 * 1000);
diff --git a/src/automats/vfsAutomat.js b/src/automats/vfsAutomat.js
new file mode 100644
index 0000000..05899e4
--- /dev/null
+++ b/src/automats/vfsAutomat.js
@@ -0,0 +1,376 @@
+/**
+ * @file vfsAutomat.js
+ * @description State container for the VFS (Virtual Filesystem) explorer and editor,
+ * integrated side-by-side with the AI assistant chat drawer.
+ *
+ * Upstream Triggers:
+ * - `authAutomat`: Subscribes to auth state changes — loads file listing on
+ * `'authed'` with a `selectedCompanyId`, and calls `actions.reset()` on `'anon'`.
+ * - `sseAutomat`: Dispatches `'vfs'` SSE mutation events to silently refresh file listing.
+ * - UI / ChatDrawer: Actions (`toggleVfs`, `selectFile`, `saveFile`, `uploadFiles`, `deleteFile`, `createFile`).
+ */
+
+import { Automat } from 'react-automat';
+import { vfs as vfsApi } from '../api';
+import authAutomat from './authAutomat';
+import sseAutomat from './sseAutomat';
+
+/**
+ * @typedef {Object} VfsFile
+ * @property {string} name Relative path in VFS (e.g. `artikelpreise.csv`, `attachments/test.pdf`)
+ * @property {number} size File size in bytes
+ * @property {string} mime Detected MIME type
+ * @property {string} updated_at ISO timestamp
+ * @property {string} created_at ISO timestamp
+ * @property {boolean} isText Whether the file can be edited in the text editor
+ * @property {boolean} isImage Whether the file can be rendered as an image
+ * @property {boolean} isPdf Whether the file is a PDF
+ */
+
+/**
+ * @typedef {Object} VfsState
+ * @property {boolean} isOpen Whether VFS editor panel is open side-by-side with chat
+ * @property {VfsFile[]} files List of files in the current company's VFS
+ * @property {boolean} loadingFiles
+ * @property {string|null} loadError
+ * @property {string|null} selectedFileName Currently selected file path
+ * @property {VfsFile|null} selectedFile Metadata of the selected file
+ * @property {string} fileContent Current text content in editor
+ * @property {string} originalContent Content as saved on server (for dirty tracking)
+ * @property {boolean} isDirty True if user made uncommitted changes
+ * @property {boolean} loadingContent True while fetching text file content
+ * @property {boolean} isSaving
+ * @property {string|null} saveError
+ * @property {boolean} isUploading
+ * @property {string|null} uploadError
+ * @property {string} searchQuery Current filter query in file explorer
+ * @property {number} vfsWidth Width of the VFS panel in pixels
+ * @property {boolean} newFileDialogOpen
+ */
+
+/** @type {VfsState} */
+const initialState = {
+ isOpen: false,
+ files: [],
+ loadingFiles: false,
+ loadError: null,
+ selectedFileName: null,
+ selectedFile: null,
+ fileContent: '',
+ originalContent: '',
+ isDirty: false,
+ loadingContent: false,
+ isSaving: false,
+ saveError: null,
+ isUploading: false,
+ uploadError: null,
+ searchQuery: '',
+ vfsWidth: 480,
+ newFileDialogOpen: false,
+};
+
+let vfsAutomat;
+
+const actions = {
+ /**
+ * Toggle or set the open state of the VFS panel in the chat drawer.
+ * @param {boolean} [nextOpen]
+ */
+ toggleVfs(nextOpen) {
+ const willOpen = typeof nextOpen === 'boolean' ? nextOpen : !vfsAutomat.state.isOpen;
+ vfsAutomat.setState({ isOpen: willOpen });
+ if (willOpen && vfsAutomat.state.files.length === 0 && !vfsAutomat.state.loadingFiles) {
+ void actions.loadFiles();
+ }
+ },
+
+ openVfs() {
+ actions.toggleVfs(true);
+ },
+
+ closeVfs() {
+ actions.toggleVfs(false);
+ },
+
+ /**
+ * Set width of the VFS editor panel.
+ * @param {number} width
+ */
+ setVfsWidth(width) {
+ const clamped = Math.max(340, Math.min(Math.round(Number(width) || 540), 1200));
+ vfsAutomat.setState({ vfsWidth: clamped });
+ },
+
+ setSearchQuery(searchQuery) {
+ vfsAutomat.setState({ searchQuery });
+ },
+
+ setNewFileDialogOpen(newFileDialogOpen) {
+ vfsAutomat.setState({ newFileDialogOpen });
+ },
+
+ /**
+ * Load or refresh the file listing for the current company.
+ * @param {boolean} [silent=false]
+ */
+ async loadFiles(silent = false) {
+ if (!silent) {
+ vfsAutomat.setState({ loadingFiles: true, loadError: null });
+ }
+ try {
+ const res = await vfsApi.list();
+ const files = Array.isArray(res?.files) ? res.files : [];
+ const currentSelected = vfsAutomat.state.selectedFileName;
+ const updatedSelectedFile = currentSelected
+ ? files.find((f) => f.name === currentSelected) || null
+ : null;
+
+ vfsAutomat.setState({
+ files,
+ selectedFile: updatedSelectedFile,
+ loadingFiles: false,
+ loadError: null,
+ });
+ return files;
+ } catch (err) {
+ console.error('[vfsAutomat] loadFiles failed:', err);
+ vfsAutomat.setState({
+ loadingFiles: false,
+ loadError: err?.message || 'Fehler beim Laden des VFS-Dateisystems',
+ });
+ return [];
+ }
+ },
+
+ /**
+ * Select a file for viewing or editing.
+ * @param {string|null} fileName
+ */
+ async selectFile(fileName) {
+ if (!fileName) {
+ vfsAutomat.setState({
+ selectedFileName: null,
+ selectedFile: null,
+ fileContent: '',
+ originalContent: '',
+ isDirty: false,
+ loadingContent: false,
+ saveError: null,
+ });
+ return;
+ }
+
+ const file = vfsAutomat.state.files.find((f) => f.name === fileName) || {
+ name: fileName,
+ isText: true,
+ };
+
+ vfsAutomat.setState({
+ selectedFileName: fileName,
+ selectedFile: file,
+ saveError: null,
+ isDirty: false,
+ });
+
+ if (file.isText) {
+ vfsAutomat.setState({ loadingContent: true });
+ try {
+ const text = await vfsApi.getText(fileName);
+ // Only apply if still selected
+ if (vfsAutomat.state.selectedFileName === fileName) {
+ vfsAutomat.setState({
+ fileContent: text,
+ originalContent: text,
+ isDirty: false,
+ loadingContent: false,
+ });
+ }
+ } catch (err) {
+ console.error(`[vfsAutomat] Failed to load text for "${fileName}":`, err);
+ if (vfsAutomat.state.selectedFileName === fileName) {
+ vfsAutomat.setState({
+ loadingContent: false,
+ saveError: `Fehler beim Laden: ${err.message}`,
+ });
+ }
+ }
+ } else {
+ vfsAutomat.setState({
+ fileContent: '',
+ originalContent: '',
+ isDirty: false,
+ loadingContent: false,
+ });
+ }
+ },
+
+ /**
+ * Update the in-memory draft content of the currently open text file.
+ * @param {string} content
+ */
+ updateContent(content) {
+ const isDirty = content !== vfsAutomat.state.originalContent;
+ vfsAutomat.setState({ fileContent: content, isDirty });
+ },
+
+ /**
+ * Save changes to the currently selected file.
+ */
+ async saveCurrentFile() {
+ const { selectedFileName, selectedFile, fileContent, isSaving } = vfsAutomat.state;
+ if (!selectedFileName || isSaving) return;
+
+ vfsAutomat.setState({ isSaving: true, saveError: null });
+ try {
+ await vfsApi.save(selectedFileName, fileContent, selectedFile?.mime);
+ vfsAutomat.setState({
+ originalContent: fileContent,
+ isDirty: false,
+ isSaving: false,
+ saveError: null,
+ });
+ void actions.loadFiles(true);
+ } catch (err) {
+ console.error('[vfsAutomat] saveCurrentFile failed:', err);
+ vfsAutomat.setState({
+ isSaving: false,
+ saveError: err?.message || 'Speichern fehlgeschlagen',
+ });
+ throw err;
+ }
+ },
+
+ /**
+ * Create a new text file and immediately open it in the editor.
+ * @param {string} name
+ * @param {string} [initialContent='']
+ */
+ async createFile(name, initialContent = '') {
+ const clean = String(name || '').trim().replace(/^[\/\\]+/, '');
+ if (!clean) throw new Error('Dateiname darf nicht leer sein');
+
+ vfsAutomat.setState({ isSaving: true, saveError: null, newFileDialogOpen: false });
+ try {
+ await vfsApi.save(clean, initialContent);
+ await actions.loadFiles(true);
+ await actions.selectFile(clean);
+ vfsAutomat.setState({ isSaving: false });
+ } catch (err) {
+ console.error('[vfsAutomat] createFile failed:', err);
+ vfsAutomat.setState({
+ isSaving: false,
+ saveError: err?.message || 'Erstellen der Datei fehlgeschlagen',
+ });
+ throw err;
+ }
+ },
+
+ /**
+ * Delete a file from VFS.
+ * @param {string} fileName
+ */
+ async deleteFile(fileName) {
+ if (!fileName) return;
+ try {
+ await vfsApi.delete(fileName);
+ if (vfsAutomat.state.selectedFileName === fileName) {
+ actions.selectFile(null);
+ }
+ await actions.loadFiles(true);
+ } catch (err) {
+ console.error('[vfsAutomat] deleteFile failed:', err);
+ throw err;
+ }
+ },
+
+ /**
+ * Upload one or more files to VFS.
+ * @param {File[]|FileList|File} files
+ * @param {string} [targetDir='']
+ */
+ async uploadFiles(files, targetDir = '') {
+ vfsAutomat.setState({ isUploading: true, uploadError: null });
+ try {
+ const res = await vfsApi.upload(files, targetDir);
+ await actions.loadFiles(true);
+ vfsAutomat.setState({ isUploading: false, uploadError: null });
+
+ // If exactly one file was uploaded, select it
+ if (res?.uploaded?.length === 1) {
+ void actions.selectFile(res.uploaded[0].name);
+ }
+ return res;
+ } catch (err) {
+ console.error('[vfsAutomat] uploadFiles failed:', err);
+ vfsAutomat.setState({
+ isUploading: false,
+ uploadError: err?.message || 'Upload fehlgeschlagen',
+ });
+ throw err;
+ }
+ },
+
+ /**
+ * Download a VFS file directly via browser.
+ * @param {string} fileName
+ */
+ downloadFile(fileName) {
+ if (!fileName) return;
+ const clean = String(fileName).replace(/^\/+/, '');
+ const url = `/api/vfs/${clean}?download=1`;
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = clean.split('/').pop() || 'download';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ },
+
+ /**
+ * Reset state (on logout or company switch).
+ */
+ reset() {
+ vfsAutomat.setState({
+ files: [],
+ loadingFiles: false,
+ loadError: null,
+ selectedFileName: null,
+ selectedFile: null,
+ fileContent: '',
+ originalContent: '',
+ isDirty: false,
+ loadingContent: false,
+ isSaving: false,
+ saveError: null,
+ isUploading: false,
+ uploadError: null,
+ searchQuery: '',
+ });
+ },
+};
+
+vfsAutomat = new Automat(initialState, actions);
+
+// Subscriptions for lifecycle coordination:
+authAutomat.subscribe((authState) => {
+ if (authState.status === 'authed' && authState.user?.selectedCompanyId) {
+ if (vfsAutomat.state.isOpen) {
+ void vfsAutomat.actions.loadFiles(true);
+ }
+ } else if (authState.status === 'anon') {
+ vfsAutomat.actions.reset();
+ }
+});
+
+// SSE auto-refresh on 'vfs' events
+sseAutomat.actions.on('vfs', () => {
+ void vfsAutomat.actions.loadFiles(true);
+});
+
+// Register on window for devtools inspector
+if (typeof window !== 'undefined') {
+ window.__AUTOMATS__ = window.__AUTOMATS__ || {};
+ window.__AUTOMATS__.vfsAutomat = vfsAutomat;
+}
+
+export default vfsAutomat;
diff --git a/src/components/chat/ChatDrawer.jsx b/src/components/chat/ChatDrawer.jsx
index 5e06c4a..d25e770 100644
--- a/src/components/chat/ChatDrawer.jsx
+++ b/src/components/chat/ChatDrawer.jsx
@@ -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 {
)}
-
-
-
+
+
+ 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',
+ },
+ }}
+ >
+
+
+
+
+
+
+
+
+
{/* Conversation Selector Bar */}
{conversations.length > 0 && (
@@ -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 {
/>
- {this.renderHeader()}
- {this.renderBody()}
- {this.renderFooter()}
+ {/* Left: VFS Filesystem Editor Panel (parallel to chat) */}
+ {isVfsOpen && (
+
+
+
+ )}
+
+ {/* Vertical Split Resizer between VFS and Chat */}
+ {isVfsOpen && (
+
+
+ theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.25)',
+ }}
+ />
+
+ )}
+
+ {/* Right: Chat Panel */}
+
+ {this.renderHeader()}
+ {this.renderBody()}
+ {this.renderFooter()}
+
>
);
}
+
}
diff --git a/src/components/tiles/ArticleTile.jsx b/src/components/tiles/ArticleTile.jsx
index 3f21ba0..74b7312 100644
--- a/src/components/tiles/ArticleTile.jsx
+++ b/src/components/tiles/ArticleTile.jsx
@@ -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 ? (
-
- ) : null}
- {label ? (
-
- {label}
-
- ) : null}
- {sku ? (
-
- {sku}
-
- ) : null}
- {grossPrice != null && (
-
- {formatPrice(grossPrice)}
-
- )}
+ {/* Row 1: Brand */}
+
+ {article?.brand?.name ? (
+
+ ) : null}
+
+
+ {/* Rows 2 & 3: Name */}
+
+ {label || article?.name?.trim() || '\u00A0'}
+
+
+ {/* Row 4: SKU */}
+
+ {sku || '\u00A0'}
+
+
+ {/* Row 5: Price */}
+
+ {grossPrice != null ? formatPrice(grossPrice) : '\u00A0'}
+
);
diff --git a/src/components/vfs/VfsEditorPanel.jsx b/src/components/vfs/VfsEditorPanel.jsx
new file mode 100644
index 0000000..0bb9551
--- /dev/null
+++ b/src/components/vfs/VfsEditorPanel.jsx
@@ -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})`;
+ this.props.onInsertToChat(tag);
+ }
+ };
+
+ render() {
+ const {
+ files,
+ loadingFiles,
+ selectedFileName,
+ selectedFile,
+ fileContent,
+ originalContent,
+ isDirty,
+ loadingContent,
+ isSaving,
+ saveError,
+ searchQuery,
+ newFileDialogOpen,
+ } = this.state;
+
+ return (
+
+ {selectedFileName && selectedFile?.isText ? (
+ /* Single Column Mode: Text Editor */
+ 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) */
+ vfsAutomat.actions.selectFile(null)}
+ onDownload={() => vfsAutomat.actions.downloadFile(selectedFileName)}
+ onMentionInChat={() => this.handleMentionInChat(selectedFile)}
+ />
+ ) : (
+ /* Single Column Mode: File Explorer */
+ <>
+
+
+
+
+ VFS Dateisystem
+
+
+
+
+ vfsAutomat.actions.closeVfs()}
+ aria-label="VFS schließen"
+ >
+
+
+
+
+
+
+ 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}
+ />
+
+ >
+ )}
+
+ {/* New File Dialog */}
+ vfsAutomat.actions.setNewFileDialogOpen(false)}
+ onCreate={(name) => vfsAutomat.actions.createFile(name)}
+ isCreating={isSaving}
+ />
+
+ );
+ }
+}
diff --git a/src/components/vfs/VfsFileExplorer.jsx b/src/components/vfs/VfsFileExplorer.jsx
new file mode 100644
index 0000000..f582dc2
--- /dev/null
+++ b/src/components/vfs/VfsFileExplorer.jsx
@@ -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 (
+
+ {/* Folder Header Row */}
+ 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 */}
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Folder Icon */}
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {/* Folder Name */}
+
+ {folder.name}
+
+
+ {/* Total Files Count in Folder */}
+
+ {folder.totalFileCount}
+
+
+
+ {/* Folder Children (Directories FIRST, then files) */}
+ {isExpanded && (
+
+ {/* 1. Subdirectories FIRST */}
+ {subfolders.map((sub) => this.renderFolderNode(sub, depth + 1))}
+
+ {/* 2. Files NEXT */}
+ {files.map((file) => this.renderFileNode(file, depth + 1))}
+
+ )}
+
+ );
+ };
+
+ renderFileNode = (file, depth) => {
+ const { selectedFileName, onSelectFile } = this.props;
+ const isSelected = file.path === selectedFileName;
+ const badge = getCategoryBadge(file.path, file.fileData.mime);
+
+ return (
+ 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',
+ },
+ })}
+ >
+
+
+
+
+
+ {file.name}
+
+
+ theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
+ color: badge.color,
+ }}
+ />
+
+
+
+
+ {formatVfsSize(file.fileData.size)}
+
+ {file.fileData.updated_at && (
+
+ {formatVfsDate(file.fileData.updated_at)}
+
+ )}
+
+
+
+ {/* Quick action buttons on hover / select */}
+ e.stopPropagation()}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 0.2,
+ opacity: isSelected ? 1 : 0.7,
+ '&:hover': { opacity: 1 },
+ }}
+ >
+
+ this.props.onDownload(file.path)}
+ sx={{ p: 0.5 }}
+ >
+
+
+
+
+ this.handleOpenMenu(e, file.fileData)}
+ sx={{ p: 0.5 }}
+ >
+
+
+
+
+ );
+ };
+
+ renderTree() {
+ const { files, searchQuery } = this.props;
+ const { root, totalCount } = buildFileTree(files, searchQuery);
+
+ if (totalCount === 0) {
+ return (
+
+
+
+ {searchQuery ? `Keine Dateien für "${searchQuery}" gefunden` : 'Keine Dateien im VFS vorhanden'}
+
+ {!searchQuery && (
+
+ Klicke oben auf Upload oder erstelle eine neue Datei.
+
+ )}
+
+ );
+ }
+
+ 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 (
+
+ {/* Top-level directories FIRST */}
+ {rootFolders.map((folder) => this.renderFolderNode(folder, 0))}
+
+ {/* Top-level files NEXT */}
+ {rootFiles.map((file) => this.renderFileNode(file, 0))}
+
+ );
+ }
+
+ render() {
+ const {
+ loading,
+ onRefresh,
+ onOpenNewDialog,
+ searchQuery,
+ onSearchChange,
+ files,
+ } = this.props;
+
+ const { menuAnchor, menuFile, isDraggingOver } = this.state;
+ const isText = menuFile?.isText;
+
+ return (
+
+ {/* Hidden Upload Input */}
+
+
+ {/* Drag & Drop Overlay */}
+ {isDraggingOver && (
+
+
+
+ Dateien im VFS ablegen
+
+
+ Die Dateien werden sofort auf den Server hochgeladen
+
+
+ )}
+
+ {/* Explorer Header / Actions */}
+
+ {/* Top Bar with Action Buttons */}
+
+
+ {loading && }
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onClick={this.handleTriggerUpload}
+ sx={{ borderRadius: 2, py: 0.3, px: 1, fontSize: '0.75rem' }}
+ >
+ Upload
+
+
+
+
+ }
+ onClick={onOpenNewDialog}
+ sx={{ borderRadius: 2, py: 0.3, px: 1, fontSize: '0.75rem' }}
+ >
+ Neu
+
+
+
+
+
+ {/* Search Filter Bar */}
+ ({
+ 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',
+ },
+ })}
+ >
+
+ onSearchChange(e.target.value)}
+ sx={{ fontSize: '0.82rem' }}
+ />
+ {searchQuery && (
+ onSearchChange('')} sx={{ p: 0.2 }}>
+
+
+ )}
+
+
+
+ {/* Scrollable File List */}
+
+ {this.renderTree()}
+
+
+ {/* Context Menu */}
+
+
+ );
+ }
+}
diff --git a/src/components/vfs/VfsFileIcon.jsx b/src/components/vfs/VfsFileIcon.jsx
new file mode 100644
index 0000000..5abdb78
--- /dev/null
+++ b/src/components/vfs/VfsFileIcon.jsx
@@ -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 (
+
+
+
+ );
+}
+
+export function getCategoryBadge(fileName, mime) {
+ const category = getVfsCategory(fileName, mime);
+ return CATEGORY_CONFIG[category] || CATEGORY_CONFIG.binary;
+}
diff --git a/src/components/vfs/VfsFilePreview.jsx b/src/components/vfs/VfsFilePreview.jsx
new file mode 100644
index 0000000..e1f509b
--- /dev/null
+++ b/src/components/vfs/VfsFilePreview.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ renderPdfPreview(url, fileName) {
+ return (
+
+
+
+ PDF-Dokument
+
+
+ Klicke unten, um das Dokument in einem neuen Tab zu öffnen oder herunterzuladen.
+
+
+ }
+ href={url}
+ target="_blank"
+ rel="noopener noreferrer"
+ sx={{ borderRadius: 2 }}
+ >
+ Im Browser öffnen
+
+ }
+ onClick={this.props.onDownload}
+ sx={{ borderRadius: 2 }}
+ >
+ Herunterladen
+
+
+
+ );
+ }
+
+ renderBinaryPreview(fileName, file) {
+ return (
+
+
+
+ Binärdatei
+
+
+ Dieser Dateityp kann nicht direkt im Browser bearbeitet werden.
+
+ }
+ onClick={this.props.onDownload}
+ sx={{ borderRadius: 2, mt: 1 }}
+ >
+ Datei herunterladen ({formatVfsSize(file?.size)})
+
+
+ );
+ }
+
+ 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 (
+
+ {/* Preview Header */}
+
+
+
+
+
+
+
+
+
+ {fileName}
+
+
+ theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
+ color: badge.color,
+ }}
+ />
+
+
+
+ {onMentionInChat && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Preview Content */}
+ {file?.isImage && this.renderImagePreview(vfsUrl)}
+ {file?.isPdf && this.renderPdfPreview(vfsUrl, fileName)}
+ {!file?.isImage && !file?.isPdf && this.renderBinaryPreview(fileName, file)}
+
+ {/* Footer info bar */}
+
+ {formatVfsSize(file?.size)}
+ {file?.mime || 'application/octet-stream'}
+ {file?.updated_at && {formatVfsDate(file.updated_at)}}
+
+
+ );
+ }
+}
diff --git a/src/components/vfs/VfsNewFileDialog.jsx b/src/components/vfs/VfsNewFileDialog.jsx
new file mode 100644
index 0000000..bc51a46
--- /dev/null
+++ b/src/components/vfs/VfsNewFileDialog.jsx
@@ -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 (
+
+ );
+ }
+}
diff --git a/src/components/vfs/VfsTextEditor.jsx b/src/components/vfs/VfsTextEditor.jsx
new file mode 100644
index 0000000..e397c8c
--- /dev/null
+++ b/src/components/vfs/VfsTextEditor.jsx
@@ -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(
+
+ {i}
+
+ );
+ }
+ 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 (
+
+ theme.palette.mode === 'dark' ? '#14171a' : '#f8f9fa',
+ overflow: 'hidden',
+ }}
+ >
+ {/* Editor Top Bar */}
+
+ {/* File Name & Status */}
+
+
+
+
+
+
+
+
+ {fileName}
+
+
+ {isDirty ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Action Buttons */}
+
+ {onMentionInChat && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ {isDirty && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ : }
+ onClick={onSave}
+ disabled={!isDirty || isSaving}
+ sx={{
+ borderRadius: 2,
+ px: 1.5,
+ py: 0.4,
+ fontSize: '0.78rem',
+ fontWeight: 600,
+ }}
+ >
+ {isSaving ? 'Speichern…' : 'Speichern (Strg+S)'}
+
+
+
+
+
+
+
+
+
+
+ {/* Error notification if save failed */}
+ {saveError && (
+
+ {saveError}
+
+ )}
+
+ {/* Editor Area */}
+
+ {loadingContent ? (
+
+
+ Lade Dateiinhalt…
+
+ ) : (
+ <>
+ {/* Line Numbers Gutter (shown when wrap is off to maintain line alignment) */}
+ {!this.state.wrapLines && (
+ ({
+ 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)}
+
+ )}
+
+ {/* Textarea Code Input */}
+ {
+ 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',
+ },
+ })}
+ />
+ >
+ )}
+
+
+ {/* Status Bar */}
+
+
+
+ Zeile {this.state.cursorLine}, Spalte {this.state.cursorCol}
+
+ {lineCount} Zeilen
+ {charCount} Zeichen
+ {formatVfsSize(charCount)}
+
+
+
+ UTF-8
+ {file?.mime || 'text/plain'}
+
+
+
+ );
+ }
+}
diff --git a/src/components/vfs/index.js b/src/components/vfs/index.js
new file mode 100644
index 0000000..270fc40
--- /dev/null
+++ b/src/components/vfs/index.js
@@ -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';
diff --git a/src/components/vfs/vfsUtils.js b/src/components/vfs/vfsUtils.js
new file mode 100644
index 0000000..70f0788
--- /dev/null
+++ b/src/components/vfs/vfsUtils.js
@@ -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),
+ };
+}