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

View File

@@ -156,6 +156,9 @@ export {
selectHasPendingAsk,
} from './chatAutomat';
export { default as vfsAutomat } from './vfsAutomat';
/**
* ============================================================================
* Overview of Automats in Dashfront

View File

@@ -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);

376
src/automats/vfsAutomat.js Normal file
View File

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