529 lines
16 KiB
JavaScript
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;
|