From 6af150aea4322206e5ec9062b4f0f060a826abb6 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 10 Oct 2026 05:53:51 +0200 Subject: [PATCH] u --- .agents/skills/backend/SKILL.md | 5 +- src/components/vfs/VfsAppletForm.jsx | 157 ++++++++++++++++++++ src/components/vfs/VfsAppletRunner.jsx | 196 +++++++++++++++++++++++++ src/components/vfs/VfsEditorPanel.jsx | 41 +++++- src/components/vfs/VfsFileExplorer.jsx | 23 ++- src/components/vfs/VfsFileIcon.jsx | 6 + src/components/vfs/VfsTextEditor.jsx | 21 +++ src/components/vfs/index.js | 2 + src/components/vfs/vfsUtils.js | 88 ++++++++++- 9 files changed, 534 insertions(+), 5 deletions(-) create mode 100644 src/components/vfs/VfsAppletForm.jsx create mode 100644 src/components/vfs/VfsAppletRunner.jsx diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index ee6b62b..1f8cf2d 100644 --- a/.agents/skills/backend/SKILL.md +++ b/.agents/skills/backend/SKILL.md @@ -7,7 +7,7 @@ description: >- # Dash API Routes Overview (All) -Overview of all 206 API endpoints across all modules. +Overview of all 207 API endpoints across all modules. ### Notation @@ -689,6 +689,9 @@ _none_ #### Get a virtual filesystem file — `GET /vfs/{*path}` - Params: `path: str, query: download?: bool, raw?: bool` - Returns: `200 str` +#### Execute a virtual filesystem applet — `POST /vfs/{*path}` +- Params: `path: str, body?: { input?: {}, timeout?: int, max_output?: int }` +- Returns: `200 { ok: bool, result: any|null, stdout: str, consoleLines: int, durationMs: int, truncated?: bool, truncationNote?: str }` #### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}` - Params: `path: str, body: { content: str, mime?: str }` - Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }` diff --git a/src/components/vfs/VfsAppletForm.jsx b/src/components/vfs/VfsAppletForm.jsx new file mode 100644 index 0000000..f1122b1 --- /dev/null +++ b/src/components/vfs/VfsAppletForm.jsx @@ -0,0 +1,157 @@ +import React, { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import TextField from '@mui/material/TextField'; +import Switch from '@mui/material/Switch'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +/** + * @file VfsAppletForm.jsx + * @description Schema-driven form renderer for applet input/output schemas. + * Renders one field per `schema.properties` entry. Supports string, number, + * integer, boolean, array and object types. Array/object values are edited + * as JSON text. In `readOnly` mode all fields are disabled (result preview). + */ +export default class VfsAppletForm extends PureComponent { + handleFieldChange = (key, nextVal) => { + const { values, onChange } = this.props; + if (!onChange) return; + onChange({ ...(values || {}), [key]: nextVal }); + }; + + handleJsonTextChange = (key, type, rawText) => { + // Try to keep parsed JSON in state; fall back to raw text while typing. + const trimmed = String(rawText || '').trim(); + if (trimmed === '') { + this.handleFieldChange(key, type === 'array' ? [] : {}); + return; + } + try { + this.handleFieldChange(key, JSON.parse(rawText)); + } catch { + this.handleFieldChange(key, rawText); + } + }; + + renderField = (key, prop) => { + const { values, readOnly } = this.props; + const raw = values?.[key]; + const type = prop?.type || 'string'; + const title = prop?.title || key; + const required = Array.isArray(this.props.schema?.required) + && this.props.schema.required.includes(key); + const label = required ? `${title} *` : title; + + if (type === 'boolean') { + return ( + this.handleFieldChange(key, e.target.checked)} + /> + } + label={ + + + {label} + + {prop?.description && ( + + {prop.description} + + )} + + } + sx={{ m: 0, py: 0.25 }} + /> + ); + } + + if (type === 'number' || type === 'integer') { + return ( + { + const v = e.target.value; + if (v === '') { + this.handleFieldChange(key, ''); + } else { + const num = Number(v); + this.handleFieldChange(key, Number.isNaN(num) ? v : num); + } + }} + sx={{ '& .MuiInputBase-input': { fontSize: '0.85rem' } }} + /> + ); + } + + if (type === 'array' || type === 'object') { + const text = typeof raw === 'string' ? raw : JSON.stringify(raw ?? (type === 'array' ? [] : {}), null, 2); + return ( + this.handleJsonTextChange(key, type, e.target.value)} + sx={{ + '& .MuiInputBase-input': { fontFamily: 'monospace', fontSize: '0.78rem' }, + }} + /> + ); + } + + // Default: string (also covers enum -> plain text for now) + return ( + this.handleFieldChange(key, e.target.value)} + inputProps={{ autoComplete: 'off', autoCorrect: 'off', autoCapitalize: 'off', spellCheck: false }} + sx={{ '& .MuiInputBase-input': { fontSize: '0.85rem' } }} + /> + ); + }; + + render() { + const { schema, emptyHint } = this.props; + const props = schema?.properties; + const keys = props && typeof props === 'object' ? Object.keys(props) : []; + + if (keys.length === 0) { + return ( + + {emptyHint || 'Keine Felder im Schema definiert.'} + + ); + } + + return ( + + {keys.map((key) => this.renderField(key, props[key]))} + + ); + } +} diff --git a/src/components/vfs/VfsAppletRunner.jsx b/src/components/vfs/VfsAppletRunner.jsx new file mode 100644 index 0000000..2dfec30 --- /dev/null +++ b/src/components/vfs/VfsAppletRunner.jsx @@ -0,0 +1,196 @@ +import React, { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import Divider from '@mui/material/Divider'; +import Alert from '@mui/material/Alert'; +import CircularProgress from '@mui/material/CircularProgress'; +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; +import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; +import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded'; +import InputRoundedIcon from '@mui/icons-material/InputRounded'; +import OutputRoundedIcon from '@mui/icons-material/OutputRounded'; + +import VfsFileIcon from './VfsFileIcon'; +import VfsAppletForm from './VfsAppletForm'; +import { parseAppletSchemas, buildAppletDefaults } from './vfsUtils'; + +/** + * @file VfsAppletRunner.jsx + * @description UI shell for `*.apple.js` applet files (forms-only, no execution yet). + * Shows input form (editable) and output form (read-only) parsed from + * `// @applet-input-schema {...}` / `// @applet-output-schema {...}` markers, + * plus a Run button that intentionally does nothing for now. + */ +export default class VfsAppletRunner extends PureComponent { + constructor(props) { + super(props); + const { inputSchema, outputSchema } = parseAppletSchemas(props.content || ''); + this.state = { + inputSchema, + outputSchema, + inputValues: buildAppletDefaults(inputSchema), + // Output preview starts with schema defaults (read-only, no execution yet). + outputValues: buildAppletDefaults(outputSchema), + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.content !== this.props.content || prevProps.fileName !== this.props.fileName) { + const { inputSchema, outputSchema } = parseAppletSchemas(this.props.content || ''); + this.setState({ + inputSchema, + outputSchema, + inputValues: buildAppletDefaults(inputSchema), + outputValues: buildAppletDefaults(outputSchema), + }); + } + } + + handleInputChange = (nextValues) => { + this.setState({ inputValues: nextValues }); + }; + + // Intentionally a no-op: execution will be wired up in a later step. + handleRun = () => {}; + + render() { + const { fileName, file, loadingContent, onClose, onShowCode, onMentionInChat } = this.props; + const { inputSchema, outputSchema, inputValues, outputValues } = this.state; + + return ( + + {/* Runner Header */} + + + + + + + + {fileName} + + + + + {onShowCode && ( + + )} + {onMentionInChat && ( + + + + )} + + + + + + + {/* Runner Content */} + + {loadingContent ? ( + + + Lade Applet… + + ) : ( + <> + {/* Input Section */} + + + + + Eingabe + + + + + + + + {/* Run Button (no-op for now) */} + + + + Führt das Applet noch nicht aus (nur Formular-Vorschau). + + + + + + {/* Output Section (read-only preview) */} + + + + + Ergebnis + + + + + + {!inputSchema && !outputSchema && ( + + In dieser Datei wurden keine Applet-Schemas gefunden. Erwartet werden Kopfzeilen wie + + {'// @applet-input-schema {"type":"object","properties":{...}}'} +
+ {'// @applet-output-schema {"type":"object","properties":{...}}'} +
+
+ )} + + )} +
+
+ ); + } +} diff --git a/src/components/vfs/VfsEditorPanel.jsx b/src/components/vfs/VfsEditorPanel.jsx index 145ceb7..81a0e3a 100644 --- a/src/components/vfs/VfsEditorPanel.jsx +++ b/src/components/vfs/VfsEditorPanel.jsx @@ -9,7 +9,9 @@ import vfsAutomat from '../../automats/vfsAutomat'; import VfsFileExplorer from './VfsFileExplorer'; import VfsTextEditor from './VfsTextEditor'; import VfsFilePreview from './VfsFilePreview'; +import VfsAppletRunner from './VfsAppletRunner'; import VfsNewFileDialog from './VfsNewFileDialog'; +import { isAppletFile } from './vfsUtils'; export default class VfsEditorPanel extends PureComponent { constructor(props) { @@ -31,6 +33,8 @@ export default class VfsEditorPanel extends PureComponent { searchQuery: s.searchQuery, newFileDialogOpen: s.newFileDialogOpen, newFileDefaultFolder: s.newFileDefaultFolder, + // Applet runner view: 'run' shows input/output forms, 'code' shows text editor. + appletView: 'run', }; } @@ -58,6 +62,15 @@ export default class VfsEditorPanel extends PureComponent { } } + componentDidUpdate(prevProps, prevState) { + // Reset to runner view whenever another file gets selected. + if (prevState.selectedFileName !== this.state.selectedFileName) { + if (this.state.appletView !== 'run') { + this.setState({ appletView: 'run' }); + } + } + } + componentWillUnmount() { this.unsub?.(); } @@ -86,6 +99,13 @@ export default class VfsEditorPanel extends PureComponent { } }; + handleRunApplet = (fileName) => { + if (!fileName) return; + // Switch to runner view first so the forms show immediately on select. + this.setState({ appletView: 'run' }); + void vfsAutomat.actions.selectFile(fileName); + }; + render() { const { files, @@ -100,8 +120,12 @@ export default class VfsEditorPanel extends PureComponent { saveError, searchQuery, newFileDialogOpen, + appletView, } = this.state; + const isApplet = Boolean(selectedFileName && isAppletFile(selectedFileName)); + const showAppletRunner = isApplet && selectedFile?.isText && appletView === 'run'; + return ( - {selectedFileName && selectedFile?.isText ? ( + {showAppletRunner ? ( + /* Applet Runner Mode: Input/Output forms + no-op Run button */ + vfsAutomat.actions.selectFile(null)} + onShowCode={() => this.setState({ appletView: 'code' })} + onMentionInChat={() => this.handleMentionInChat(selectedFile)} + /> + ) : selectedFileName && selectedFile?.isText ? ( /* Single Column Mode: Text Editor */ vfsAutomat.actions.selectFile(null)} onDownload={() => vfsAutomat.actions.downloadFile(selectedFileName)} onMentionInChat={(lineInfo) => this.handleMentionInChat(selectedFile, lineInfo)} + // Applets get an exe affordance to switch back to the runner forms. + isApplet={isApplet} + onShowRunner={isApplet ? () => this.setState({ appletView: 'run' }) : undefined} /> ) : selectedFileName && !selectedFile?.isText ? ( /* Single Column Mode: Preview (Image, PDF, Binary) */ @@ -181,6 +219,7 @@ export default class VfsEditorPanel extends PureComponent { selectedFileName={selectedFileName} searchQuery={searchQuery} onSelectFile={(name) => vfsAutomat.actions.selectFile(name)} + onRunApplet={this.handleRunApplet} onRefresh={() => vfsAutomat.actions.loadFiles()} onOpenNewDialog={(parentDir) => vfsAutomat.actions.setNewFileDialogOpen(true, parentDir)} onSearchChange={(q) => vfsAutomat.actions.setSearchQuery(q)} diff --git a/src/components/vfs/VfsFileExplorer.jsx b/src/components/vfs/VfsFileExplorer.jsx index 0f3c7bb..4f81f58 100644 --- a/src/components/vfs/VfsFileExplorer.jsx +++ b/src/components/vfs/VfsFileExplorer.jsx @@ -25,9 +25,10 @@ import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded'; import CreateNewFolderRoundedIcon from '@mui/icons-material/CreateNewFolderRounded'; import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded'; +import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; import VfsFileIcon from './VfsFileIcon'; -import { formatVfsSize, formatVfsDate } from './vfsUtils'; +import { formatVfsSize, formatVfsDate, isAppletFile } from './vfsUtils'; import VfsNewFolderDialog from './VfsNewFolderDialog'; import VfsRenameDialog from './VfsRenameDialog'; @@ -415,8 +416,9 @@ export default class VfsFileExplorer extends PureComponent { }; renderFileNode = (file, depth) => { - const { selectedFileName, onSelectFile } = this.props; + const { selectedFileName, onSelectFile, onRunApplet } = this.props; const isSelected = file.path === selectedFileName; + const isApplet = isAppletFile(file.path); return ( + {isApplet && ( + { + e.stopPropagation(); + if (onRunApplet) { + onRunApplet(file.path); + } else { + onSelectFile(file.path); + } + }} + aria-label="Applet ausf\u00fchren" + sx={{ p: 0.5, color: 'success.main' }} + > + + + )} this.handleOpenMenu(e, file.fileData)} diff --git a/src/components/vfs/VfsFileIcon.jsx b/src/components/vfs/VfsFileIcon.jsx index 5abdb78..8a43376 100644 --- a/src/components/vfs/VfsFileIcon.jsx +++ b/src/components/vfs/VfsFileIcon.jsx @@ -8,9 +8,15 @@ import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded'; import ArticleRoundedIcon from '@mui/icons-material/ArticleRounded'; +import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded'; import { getVfsCategory } from './vfsUtils'; const CATEGORY_CONFIG = { + applet: { + icon: SmartToyRoundedIcon, + color: '#00897b', // Teal + label: 'Applet', + }, skill: { icon: AutoAwesomeRoundedIcon, color: '#9c27b0', // Purple diff --git a/src/components/vfs/VfsTextEditor.jsx b/src/components/vfs/VfsTextEditor.jsx index af0acc8..b03a561 100644 --- a/src/components/vfs/VfsTextEditor.jsx +++ b/src/components/vfs/VfsTextEditor.jsx @@ -10,6 +10,7 @@ import UndoRoundedIcon from '@mui/icons-material/UndoRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded'; +import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; import Alert from '@mui/material/Alert'; import VfsFileIcon from './VfsFileIcon'; @@ -153,6 +154,8 @@ export default class VfsTextEditor extends PureComponent { onSave, onClose, onMentionInChat, + isApplet, + onShowRunner, } = this.props; const lineCount = (content || '').split('\n').length; @@ -211,6 +214,24 @@ export default class VfsTextEditor extends PureComponent { {/* Action Buttons */} + {isApplet && onShowRunner && ( + + )} {onMentionInChat && ( diff --git a/src/components/vfs/index.js b/src/components/vfs/index.js index 270fc40..e490bb9 100644 --- a/src/components/vfs/index.js +++ b/src/components/vfs/index.js @@ -3,4 +3,6 @@ export { default as VfsFileExplorer } from './VfsFileExplorer'; export { default as VfsTextEditor } from './VfsTextEditor'; export { default as VfsFilePreview } from './VfsFilePreview'; export { default as VfsFileIcon } from './VfsFileIcon'; +export { default as VfsAppletForm } from './VfsAppletForm'; +export { default as VfsAppletRunner } from './VfsAppletRunner'; export * from './vfsUtils'; diff --git a/src/components/vfs/vfsUtils.js b/src/components/vfs/vfsUtils.js index b1dc879..a56f61e 100644 --- a/src/components/vfs/vfsUtils.js +++ b/src/components/vfs/vfsUtils.js @@ -41,12 +41,13 @@ export function formatVfsDate(isoString) { * Detect category of file. * @param {string} filePath * @param {string} [mime] - * @returns {'code'|'table'|'markdown'|'text'|'pdf'|'image'|'skill'|'binary'} + * @returns {'code'|'table'|'markdown'|'text'|'pdf'|'image'|'skill'|'applet'|'binary'} */ export function getVfsCategory(filePath, mime = '') { const p = String(filePath || '').toLowerCase(); const m = String(mime || '').toLowerCase(); + if (isAppletFile(p)) return 'applet'; if (p.endsWith('.skill')) return 'skill'; if (p.endsWith('.pdf') || m === 'application/pdf') return 'pdf'; if ( @@ -79,6 +80,17 @@ export function getVfsCategory(filePath, mime = '') { return 'binary'; } +/** + * Check whether a VFS path is an executable applet file. + * Applets are `*.apple.js` (also accepts `*.applet.js`). + * @param {string} filePath + * @returns {boolean} + */ +export function isAppletFile(filePath) { + const p = String(filePath || '').toLowerCase(); + return p.endsWith('.apple.js') || p.endsWith('.applet.js'); +} + /** * Split a VFS file path into directory and basename. * @param {string} path @@ -95,3 +107,77 @@ export function splitVfsPath(path) { base: clean.slice(lastSlash + 1), }; } + +/** + * Extract a JSON schema object from a `// @applet-*-schema {...}` marker line. + * @param {string} content + * @param {'input'|'output'} kind + * @returns {object|null} + */ +function extractAppletSchema(content, kind) { + const marker = kind === 'input' ? '@applet-input-schema' : '@applet-output-schema'; + const lines = String(content || '').split('\n'); + for (const line of lines) { + const idx = line.indexOf(marker); + if (idx === -1) continue; + const jsonPart = line.slice(idx + marker.length).trim(); + if (!jsonPart) continue; + try { + const parsed = JSON.parse(jsonPart); + if (parsed && typeof parsed === 'object') return parsed; + } catch { + // ignore malformed schema lines + } + } + return null; +} + +/** + * Parse applet input/output JSON schemas from file content. + * Looks for `// @applet-input-schema {...}` and `// @applet-output-schema {...}`. + * @param {string} content + * @returns {{ inputSchema: object|null, outputSchema: object|null }} + */ +export function parseAppletSchemas(content) { + return { + inputSchema: extractAppletSchema(content, 'input'), + outputSchema: extractAppletSchema(content, 'output'), + }; +} + +/** + * Build a default values object from a JSON schema (`properties` + `default`). + * @param {object|null} schema + * @returns {object} + */ +export function buildAppletDefaults(schema) { + const values = {}; + const props = schema?.properties; + if (!props || typeof props !== 'object') return values; + for (const key of Object.keys(props)) { + const prop = props[key] || {}; + if (prop.default !== undefined) { + values[key] = prop.default; + continue; + } + switch (prop.type) { + case 'boolean': + values[key] = false; + break; + case 'number': + case 'integer': + values[key] = ''; + break; + case 'array': + values[key] = []; + break; + case 'object': + values[key] = {}; + break; + default: + values[key] = ''; + break; + } + } + return values; +}