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