diff --git a/src/api/vfs.js b/src/api/vfs.js index 9defd26..c62956e 100644 --- a/src/api/vfs.js +++ b/src/api/vfs.js @@ -106,4 +106,25 @@ export const vfs = { body: { oldPath: cleanOld, newPath: cleanNew }, }); }, + + /** + * POST /api/vfs/*path — run a `.applet.js` file from VFS. + * Path goes in the URL, run params in the JSON body. + * @param {string} path VFS-relative applet path (e.g. `demo.applet.js`) + * @param {{ input?: object, timeout?: number, max_output?: number }} [options] + * → 200 { ok, result, columns, rows, warnings?, stdout, consoleLines, durationMs, truncated? } + * `result` is a typed CSV string (`;`-separated, header row first), `columns` are the + * declared `{ name, type }` pairs, `rows` is the data-row count. + */ + runApplet(path, options = {}) { + const clean = String(path || '').replace(/^\/+/, ''); + const body = {}; + if (options.input !== undefined) body.input = options.input; + if (options.timeout !== undefined) body.timeout = options.timeout; + if (options.max_output !== undefined) body.max_output = options.max_output; + return http(`/api/vfs/${clean}`, { + method: 'POST', + body, + }); + }, }; diff --git a/src/components/vfs/VfsAppletRunner.jsx b/src/components/vfs/VfsAppletRunner.jsx index 2dfec30..54fe84c 100644 --- a/src/components/vfs/VfsAppletRunner.jsx +++ b/src/components/vfs/VfsAppletRunner.jsx @@ -9,6 +9,7 @@ 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 DownloadRoundedIcon from '@mui/icons-material/DownloadRounded'; import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded'; import InputRoundedIcon from '@mui/icons-material/InputRounded'; @@ -16,36 +17,53 @@ import OutputRoundedIcon from '@mui/icons-material/OutputRounded'; import VfsFileIcon from './VfsFileIcon'; import VfsAppletForm from './VfsAppletForm'; -import { parseAppletSchemas, buildAppletDefaults } from './vfsUtils'; +import { parseAppletSchemas, buildAppletDefaults, parseCsvRows } from './vfsUtils'; +import { vfs as vfsApi } from '../../api'; /** * @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. + * @description Runner UI for applet files (`*.applet.js`). + * Shows an editable input form (from the `// @applet-input-schema {...}` JSON Schema) and a + * read-only result table (from the `// @applet-output-schema name:type;…` typed CSV header). + * The Run button executes the applet via `POST /api/vfs/{path}`; the response carries the + * result as a typed CSV string plus `columns`, `rows` and optional `warnings`. */ export default class VfsAppletRunner extends PureComponent { constructor(props) { super(props); - const { inputSchema, outputSchema } = parseAppletSchemas(props.content || ''); + const { inputSchema, outputColumns } = parseAppletSchemas(props.content || ''); this.state = { inputSchema, - outputSchema, + outputColumns, inputValues: buildAppletDefaults(inputSchema), - // Output preview starts with schema defaults (read-only, no execution yet). - outputValues: buildAppletDefaults(outputSchema), + isRunning: false, + runError: null, + runResult: null, + runColumns: null, + runRows: [], + runWarnings: [], + stdout: '', + durationMs: null, + truncated: false, }; } componentDidUpdate(prevProps) { if (prevProps.content !== this.props.content || prevProps.fileName !== this.props.fileName) { - const { inputSchema, outputSchema } = parseAppletSchemas(this.props.content || ''); + const { inputSchema, outputColumns } = parseAppletSchemas(this.props.content || ''); this.setState({ inputSchema, - outputSchema, + outputColumns, inputValues: buildAppletDefaults(inputSchema), - outputValues: buildAppletDefaults(outputSchema), + isRunning: false, + runError: null, + runResult: null, + runColumns: null, + runRows: [], + runWarnings: [], + stdout: '', + durationMs: null, + truncated: false, }); } } @@ -54,12 +72,179 @@ export default class VfsAppletRunner extends PureComponent { this.setState({ inputValues: nextValues }); }; - // Intentionally a no-op: execution will be wired up in a later step. - handleRun = () => {}; + /** + * Download the last run's CSV result as a file named after the applet. + */ + handleDownloadCsv = () => { + const { fileName } = this.props; + const { runResult } = this.state; + if (!runResult) return; + + const base = String(fileName || 'applet').split('/').pop().replace(/\.applet\.js$/i, ''); + const blob = new Blob([runResult], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${base || 'applet'}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + /** + * Execute the applet via `POST /api/vfs/{path}` and render the returned CSV table. + */ + handleRun = async () => { + const { fileName } = this.props; + const { inputValues, isRunning } = this.state; + if (!fileName || isRunning) return; + + this.setState({ isRunning: true, runError: null, stdout: '', truncated: false }); + + try { + const res = await vfsApi.runApplet(fileName, { input: inputValues }); + const csv = typeof res?.result === 'string' ? res.result : ''; + // The response only carries the row COUNT, so read the cells back out of the CSV. + const parsed = parseCsvRows(csv); + const header = parsed.length > 0 ? parsed[0] : []; + const body = parsed.slice(1); + const columns = Array.isArray(res?.columns) && res.columns.length > 0 + ? res.columns + : header.map((name) => ({ name, type: 'string' })); + + this.setState({ + isRunning: false, + runResult: csv, + runColumns: columns, + runRows: body, + runWarnings: Array.isArray(res?.warnings) ? res.warnings : [], + stdout: res?.stdout || '', + durationMs: res?.durationMs ?? null, + truncated: Boolean(res?.truncated), + }); + } catch (err) { + this.setState({ + isRunning: false, + runError: err?.message || 'Ausführung fehlgeschlagen', + runResult: null, + runColumns: null, + runRows: [], + runWarnings: [], + }); + } + }; + + renderResultTable() { + const { runColumns, runRows, outputColumns } = this.state; + // Before the first run there are no result columns yet, so fall back to the columns + // declared in the file header — the table then shows its shape immediately. + const columns = runColumns && runColumns.length > 0 ? runColumns : outputColumns; + if (!columns || columns.length === 0) { + return ( + + Kein @applet-output-schema im Dateikopf gefunden. + + ); + } + + return ( + + + + + {columns.map((col) => ( + + + {col.name} + + + {col.type} + + + ))} + + + + {runRows.length === 0 ? ( + + + Noch nicht ausgeführt + + + ) : ( + runRows.map((row, rowIndex) => ( + + {columns.map((col, colIndex) => ( + + {row[colIndex] ?? ''} + + ))} + + )) + )} + + + + ); + } render() { const { fileName, file, loadingContent, onClose, onShowCode, onMentionInChat } = this.props; - const { inputSchema, outputSchema, inputValues, outputValues } = this.state; + const { + inputSchema, + outputColumns, + inputValues, + isRunning, + runError, + runResult, + runWarnings, + stdout, + durationMs, + truncated, + } = this.state; return ( @@ -143,47 +328,105 @@ export default class VfsAppletRunner extends PureComponent { - {/* Run Button (no-op for now) */} - + {/* Run Button */} + - - Führt das Applet noch nicht aus (nur Formular-Vorschau). - + {durationMs != null && !isRunning && ( + + {durationMs} ms + + )} + {truncated && ( + + stdout gekürzt + + )} + {/* Run error */} + {runError && ( + + {runError} + + )} + + {/* stdout (only when the applet logged something) */} + {stdout && ( + + + Konsole + + (theme.palette.mode === 'dark' ? '#0f1215' : '#f0f2f5'), + fontFamily: 'Consolas, Monaco, "Courier New", monospace', + fontSize: '0.75rem', + lineHeight: 1.5, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + maxHeight: 220, + overflow: 'auto', + }} + > + {stdout} + + + )} + - {/* Output Section (read-only preview) */} + {/* Output Section (typed CSV result table) */} Ergebnis + {runResult && ( + + )} - + + {/* Warnings: cells dropped for not matching their declared type. */} + {runWarnings.length > 0 && ( + + {runWarnings.map((warning) => ( +
{warning}
+ ))} +
+ )} + + {this.renderResultTable()}
- {!inputSchema && !outputSchema && ( + {!inputSchema && !outputColumns && ( In dieser Datei wurden keine Applet-Schemas gefunden. Erwartet werden Kopfzeilen wie {'// @applet-input-schema {"type":"object","properties":{...}}'}
- {'// @applet-output-schema {"type":"object","properties":{...}}'} + {'// @applet-output-schema id:integer;name:string;active:boolean'}
)} diff --git a/src/components/vfs/vfsUtils.js b/src/components/vfs/vfsUtils.js index a56f61e..ba5449e 100644 --- a/src/components/vfs/vfsUtils.js +++ b/src/components/vfs/vfsUtils.js @@ -82,13 +82,13 @@ export function getVfsCategory(filePath, mime = '') { /** * Check whether a VFS path is an executable applet file. - * Applets are `*.apple.js` (also accepts `*.applet.js`). + * Applets are `*.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'); + return p.endsWith('.applet.js'); } /** @@ -109,40 +109,136 @@ export function splitVfsPath(path) { } /** - * Extract a JSON schema object from a `// @applet-*-schema {...}` marker line. - * @param {string} content - * @param {'input'|'output'} kind - * @returns {object|null} + * Column types an applet may declare in its `@applet-output-schema` header. + * Mirrors `APPLET_COLUMN_TYPES` in `dashback/src/tools/javascript.js`. */ -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 - } +export const APPLET_COLUMN_TYPES = ['string', 'integer', 'number', 'boolean', 'date']; + +/** Semicolon-separated columns, matching the backend and the `*Csv.js` spreadsheet exports. */ +export const APPLET_CSV_SEPARATOR = ';'; + +/** + * Parse an applet output header into typed CSV columns. + * + * The header carries column names AND types, e.g. `id:integer;name:string;active:boolean`. + * A bare `name` (no `:type`) defaults to `string`. Returns `null` when the header is empty, + * declares an unknown type, repeats a name, or has an empty column name — the same cases the + * backend rejects with a `ToolError`. + * + * @param {string} header Raw text after the `// @applet-output-schema ` marker. + * @returns {Array<{ name: string, type: string }>|null} + */ +export function parseTypedCsvColumns(header) { + const text = String(header || '').trim(); + if (!text) return null; + + const columns = []; + const seen = new Set(); + for (const raw of text.split(APPLET_CSV_SEPARATOR)) { + const field = raw.trim(); + if (!field) continue; + + const sep = field.lastIndexOf(':'); + const name = (sep === -1 ? field : field.slice(0, sep)).trim(); + const type = (sep === -1 ? 'string' : field.slice(sep + 1).trim().toLowerCase()); + + if (!name) return null; + if (!APPLET_COLUMN_TYPES.includes(type)) return null; + if (seen.has(name)) return null; + seen.add(name); + columns.push({ name, type }); } - return null; + + return columns.length > 0 ? columns : 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 }} + * Parse a `;`-separated CSV string (RFC 4180 quoting) into a 2D array of raw cell strings. + * + * Used to render the applet's `result` table; the response only carries the row COUNT, so the + * cells themselves have to be read back out of the CSV string. + * + * @param {string} text CSV text, header row first. + * @param {string} [separator=';'] + * @returns {string[][]} + */ +export function parseCsvRows(text, separator = APPLET_CSV_SEPARATOR) { + const source = String(text ?? ''); + if (!source) return []; + + const rows = []; + let row = []; + let field = ''; + let inQuotes = false; + + for (let i = 0; i < source.length; i += 1) { + const ch = source[i]; + if (inQuotes) { + if (ch === '"') { + if (source[i + 1] === '"') { + field += '"'; + i += 1; + } else { + inQuotes = false; + } + } else { + field += ch; + } + } else if (ch === '"') { + inQuotes = true; + } else if (ch === separator) { + row.push(field); + field = ''; + } else if (ch === '\n') { + row.push(field); + rows.push(row); + row = []; + field = ''; + } else if (ch !== '\r') { + field += ch; + } + } + + // Flush the trailing field/row, but ignore the empty row a final newline would produce. + if (field !== '' || row.length > 0) { + row.push(field); + rows.push(row); + } + + return rows; +} + +/** + * 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. + * + * @param {string} content Applet file source. + * @returns {{ inputSchema: object|null, outputColumns: Array<{name: string, type: string}>|null }} */ export function parseAppletSchemas(content) { - return { - inputSchema: extractAppletSchema(content, 'input'), - outputSchema: extractAppletSchema(content, 'output'), - }; + const lines = String(content || '').replace(/^\uFEFF/, '').split(/\r?\n/); + + let inputSchema = null; + const inputMatch = lines[0]?.match(/^\/\/ @applet-input-schema (.+)$/); + if (inputMatch) { + try { + const parsed = JSON.parse(inputMatch[1]); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) inputSchema = parsed; + } catch { + // Malformed input schema: leave null so the UI surfaces the hint. + } + } + + let outputColumns = null; + const outputMatch = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/); + if (outputMatch) { + outputColumns = parseTypedCsvColumns(outputMatch[1]); + } + + return { inputSchema, outputColumns }; } /**