diff --git a/src/automats/vfsAutomat.js b/src/automats/vfsAutomat.js
index f140114..0ac278a 100644
--- a/src/automats/vfsAutomat.js
+++ b/src/automats/vfsAutomat.js
@@ -12,6 +12,7 @@
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';
@@ -25,6 +26,7 @@ import sseAutomat from './sseAutomat';
* @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
*/
/**
@@ -71,6 +73,7 @@ const initialState = {
};
let vfsAutomat;
+const appletDescriptionCache = new Map();
const actions = {
/**
@@ -120,7 +123,17 @@ const actions = {
}
try {
const res = await vfsApi.list();
- const files = Array.isArray(res?.files) ? res.files : [];
+ 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
@@ -132,6 +145,10 @@ const actions = {
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);
@@ -143,6 +160,68 @@ const actions = {
}
},
+ /**
+ * 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
@@ -179,9 +258,18 @@ const actions = {
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,
});
@@ -224,6 +312,14 @@ const actions = {
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,
@@ -274,6 +370,7 @@ const actions = {
if (!fileName) return;
try {
await vfsApi.delete(fileName);
+ appletDescriptionCache.delete(fileName);
if (vfsAutomat.state.selectedFileName === fileName) {
actions.selectFile(null);
}
@@ -320,6 +417,11 @@ const actions = {
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) {
@@ -379,6 +481,7 @@ const actions = {
* Reset state (on logout or company switch).
*/
reset() {
+ appletDescriptionCache.clear();
vfsAutomat.setState({
files: [],
loadingFiles: false,
diff --git a/src/components/vfs/VfsAppletRunner.jsx b/src/components/vfs/VfsAppletRunner.jsx
index 54fe84c..9acb106 100644
--- a/src/components/vfs/VfsAppletRunner.jsx
+++ b/src/components/vfs/VfsAppletRunner.jsx
@@ -31,10 +31,11 @@ import { vfs as vfsApi } from '../../api';
export default class VfsAppletRunner extends PureComponent {
constructor(props) {
super(props);
- const { inputSchema, outputColumns } = parseAppletSchemas(props.content || '');
+ const { inputSchema, outputColumns, description } = parseAppletSchemas(props.content || '');
this.state = {
inputSchema,
outputColumns,
+ description: description || props.file?.description || '',
inputValues: buildAppletDefaults(inputSchema),
isRunning: false,
runError: null,
@@ -50,10 +51,11 @@ export default class VfsAppletRunner extends PureComponent {
componentDidUpdate(prevProps) {
if (prevProps.content !== this.props.content || prevProps.fileName !== this.props.fileName) {
- const { inputSchema, outputColumns } = parseAppletSchemas(this.props.content || '');
+ const { inputSchema, outputColumns, description } = parseAppletSchemas(this.props.content || '');
this.setState({
inputSchema,
outputColumns,
+ description: description || this.props.file?.description || '',
inputValues: buildAppletDefaults(inputSchema),
isRunning: false,
runError: null,
@@ -122,6 +124,7 @@ export default class VfsAppletRunner extends PureComponent {
stdout: res?.stdout || '',
durationMs: res?.durationMs ?? null,
truncated: Boolean(res?.truncated),
+ description: res?.description || this.state.description,
});
} catch (err) {
this.setState({
@@ -236,6 +239,7 @@ export default class VfsAppletRunner extends PureComponent {
const {
inputSchema,
outputColumns,
+ description: stateDescription,
inputValues,
isRunning,
runError,
@@ -245,6 +249,7 @@ export default class VfsAppletRunner extends PureComponent {
durationMs,
truncated,
} = this.state;
+ const description = stateDescription || file?.description;
return (
@@ -268,14 +273,27 @@ export default class VfsAppletRunner extends PureComponent {
-
- {fileName}
-
+
+
+ {fileName}
+
+ {description && (
+
+ {description}
+
+ )}
+
diff --git a/src/components/vfs/VfsFileExplorer.jsx b/src/components/vfs/VfsFileExplorer.jsx
index 4f81f58..5225519 100644
--- a/src/components/vfs/VfsFileExplorer.jsx
+++ b/src/components/vfs/VfsFileExplorer.jsx
@@ -55,7 +55,11 @@ function buildFileTree(files, searchQuery = '') {
const q = searchQuery ? searchQuery.trim().toLowerCase() : '';
const filtered = (files || []).filter((f) => {
if (!q) return true;
- return f.name.toLowerCase().includes(q) || (f.mime && f.mime.toLowerCase().includes(q));
+ return (
+ f.name.toLowerCase().includes(q) ||
+ (f.mime && f.mime.toLowerCase().includes(q)) ||
+ (f.description && f.description.toLowerCase().includes(q))
+ );
});
const root = {
@@ -457,25 +461,65 @@ export default class VfsFileExplorer extends PureComponent {
/>
-
- {file.name}
-
-
-
-
- {formatVfsSize(file.fileData.size)}
-
- {file.fileData.updated_at && (
-
- {formatVfsDate(file.fileData.updated_at)}
+ {file.fileData?.description ? (
+ <>
+ {/* Line 1: Description */}
+
+ {file.fileData.description}
- )}
-
+
+ {/* Line 2: size, date, filename */}
+
+
+ {formatVfsSize(file.fileData.size)}
+
+ {file.fileData.updated_at && (
+
+ {formatVfsDate(file.fileData.updated_at)}
+
+ )}
+
+ {file.name}
+
+
+ >
+ ) : (
+ <>
+ {/* Line 1: filename */}
+
+ {file.name}
+
+
+ {/* Line 2: size, date */}
+
+
+ {formatVfsSize(file.fileData.size)}
+
+ {file.fileData.updated_at && (
+
+ {formatVfsDate(file.fileData.updated_at)}
+
+ )}
+
+ >
+ )}
{/* Quick action buttons on hover / select */}
diff --git a/src/components/vfs/vfsUtils.js b/src/components/vfs/vfsUtils.js
index ba5449e..0d36fb2 100644
--- a/src/components/vfs/vfsUtils.js
+++ b/src/components/vfs/vfsUtils.js
@@ -208,15 +208,38 @@ export function parseCsvRows(text, separator = APPLET_CSV_SEPARATOR) {
return rows;
}
+/**
+ * Parse the applet description header.
+ *
+ * Accepts either a quoted string (e.g. `"Lorem ipsum"` or `'Lorem ipsum'`) or bare text.
+ * Strips surrounding quotes if present and trims whitespace.
+ *
+ * @param {string} header Raw text after the `// @applet-description ` marker.
+ * @returns {string}
+ */
+export function parseAppletDescription(header) {
+ const text = String(header || '').trim();
+ if (!text) return '';
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
+ try {
+ const parsed = JSON.parse(text);
+ if (typeof parsed === 'string' && parsed.trim()) return parsed.trim();
+ } catch {
+ const unquoted = text.slice(1, -1).trim();
+ if (unquoted) return unquoted;
+ }
+ }
+ return text;
+}
+
/**
* Parse applet metadata from the file header.
*
- * Mirrors the backend's `readApplet()`: the input JSON Schema must be on line 1 and the typed
- * CSV output columns on line 2. Parsing the same two lines keeps the UI in lockstep with what
- * the sandbox will actually accept.
+ * Mirrors the backend's `readApplet()`: the input JSON Schema must be on line 1, the typed
+ * CSV output columns on line 2, and the applet description on line 3.
*
* @param {string} content Applet file source.
- * @returns {{ inputSchema: object|null, outputColumns: Array<{name: string, type: string}>|null }}
+ * @returns {{ inputSchema: object|null, outputColumns: Array<{name: string, type: string}>|null, description: string }}
*/
export function parseAppletSchemas(content) {
const lines = String(content || '').replace(/^\uFEFF/, '').split(/\r?\n/);
@@ -238,7 +261,15 @@ export function parseAppletSchemas(content) {
outputColumns = parseTypedCsvColumns(outputMatch[1]);
}
- return { inputSchema, outputColumns };
+ let description = '';
+ const descMatch =
+ lines[2]?.match(/^\/\/ @applet-description (.+)$/) ||
+ lines.slice(0, 10).map((l) => l.match(/^\/\/ @applet-description (.+)$/)).find(Boolean);
+ if (descMatch) {
+ description = parseAppletDescription(descMatch[1]);
+ }
+
+ return { inputSchema, outputColumns, description };
}
/**