u
This commit is contained in:
@@ -156,6 +156,9 @@ export {
|
||||
selectHasPendingAsk,
|
||||
} from './chatAutomat';
|
||||
|
||||
export { default as vfsAutomat } from './vfsAutomat';
|
||||
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* Overview of Automats in Dashfront
|
||||
|
||||
@@ -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
376
src/automats/vfsAutomat.js
Normal 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;
|
||||
Reference in New Issue
Block a user