/** * @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 { isAppletFile, parseAppletSchemas } from '../components/vfs/vfsUtils'; 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 * @property {string} [description] Applet description declared in the @applet-description header */ /** * @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, newFileDefaultFolder: '', }; let vfsAutomat; const appletDescriptionCache = new Map(); 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, newFileDefaultFolder = '') { vfsAutomat.setState({ newFileDialogOpen, newFileDefaultFolder }); }, /** * 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 rawFiles = Array.isArray(res?.files) ? res.files : []; const files = rawFiles.map((f) => { if (f.description) { appletDescriptionCache.set(f.name, f.description); return f; } if (appletDescriptionCache.has(f.name)) { return { ...f, description: appletDescriptionCache.get(f.name) }; } return f; }); 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, }); // Asynchronously fetch descriptions for any applet files that don't have one yet void actions.loadAppletDescriptions(files); return files; } catch (err) { console.error('[vfsAutomat] loadFiles failed:', err); vfsAutomat.setState({ loadingFiles: false, loadError: err?.message || 'Fehler beim Laden des VFS-Dateisystems', }); return []; } }, /** * Asynchronously load description headers for applets missing cached descriptions. * @param {VfsFile[]} [files] */ async loadAppletDescriptions(files) { const list = files || vfsAutomat.state.files; const missing = list.filter((f) => isAppletFile(f.name) && !f.description); if (missing.length === 0) return; let changed = false; await Promise.all( missing.map(async (file) => { try { const text = await vfsApi.getText(file.name); const { description } = parseAppletSchemas(text); if (description) { appletDescriptionCache.set(file.name, description); changed = true; } } catch { // Ignore failures reading unreadable files } }) ); if (changed) { const nextFiles = vfsAutomat.state.files.map((f) => { const desc = appletDescriptionCache.get(f.name); return desc && f.description !== desc ? { ...f, description: desc } : f; }); const currentSelected = vfsAutomat.state.selectedFileName; const updatedSelectedFile = currentSelected ? nextFiles.find((f) => f.name === currentSelected) || null : null; vfsAutomat.setState({ files: nextFiles, selectedFile: updatedSelectedFile, }); } }, /** * Manually record or update an applet file's description in the state & cache. * @param {string} fileName * @param {string} description */ setAppletDescription(fileName, description) { if (!fileName || !description) return; appletDescriptionCache.set(fileName, description); const nextFiles = vfsAutomat.state.files.map((f) => f.name === fileName ? { ...f, description } : f ); const updatedSelectedFile = vfsAutomat.state.selectedFileName === fileName ? { ...(vfsAutomat.state.selectedFile || {}), description } : vfsAutomat.state.selectedFile; vfsAutomat.setState({ files: nextFiles, selectedFile: updatedSelectedFile, }); }, /** * 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) { let updatedFile = file; if (isAppletFile(fileName)) { const { description } = parseAppletSchemas(text); if (description) { appletDescriptionCache.set(fileName, description); updatedFile = { ...file, description }; } } vfsAutomat.setState({ fileContent: text, originalContent: text, selectedFile: updatedFile, 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); if (isAppletFile(selectedFileName)) { const { description } = parseAppletSchemas(fileContent); if (description) { appletDescriptionCache.set(selectedFileName, description); } else { appletDescriptionCache.delete(selectedFileName); } } 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); appletDescriptionCache.delete(fileName); if (vfsAutomat.state.selectedFileName === fileName) { actions.selectFile(null); } await actions.loadFiles(true); } catch (err) { console.error('[vfsAutomat] deleteFile failed:', err); throw err; } }, /** * Delete a file or directory from VFS. * @param {string} path */ async deletePath(path) { return actions.deleteFile(path); }, /** * Create a new directory in VFS. * @param {string} path */ async mkdir(path) { const clean = String(path || '').trim().replace(/^[/\\]+/, ''); if (!clean) throw new Error('Pfad darf nicht leer sein'); try { const res = await vfsApi.mkdir(clean); await actions.loadFiles(true); return res; } catch (err) { console.error('[vfsAutomat] mkdir failed:', err); throw err; } }, /** * Rename or move a file or directory in VFS. * @param {string} oldPath * @param {string} newPath */ async renameFile(oldPath, newPath) { const cleanOld = String(oldPath || '').trim().replace(/^[/\\]+/, ''); const cleanNew = String(newPath || '').trim().replace(/^[/\\]+/, ''); if (!cleanOld || !cleanNew) throw new Error('Alter und neuer Pfad dürfen nicht leer sein'); try { const res = await vfsApi.rename(cleanOld, cleanNew); if (appletDescriptionCache.has(cleanOld)) { const desc = appletDescriptionCache.get(cleanOld); appletDescriptionCache.delete(cleanOld); appletDescriptionCache.set(cleanNew, desc); } const isCurrentSelected = vfsAutomat.state.selectedFileName === cleanOld; await actions.loadFiles(true); if (isCurrentSelected) { await actions.selectFile(cleanNew); } return res; } catch (err) { console.error('[vfsAutomat] renameFile 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() { appletDescriptionCache.clear(); 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;