Files
dashfront/src/automats/vfsAutomat.js
2026-10-12 14:30:26 +02:00

529 lines
16 KiB
JavaScript

/**
* @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;