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 */}
+
}
+ startIcon={isRunning ? : }
onClick={this.handleRun}
- title="Ausführung folgt in einem späteren Schritt"
+ disabled={isRunning || !fileName}
sx={{ borderRadius: 2, fontWeight: 700 }}
>
- Ausführen
+ {isRunning ? 'Läuft…' : 'Ausführen'}
-
- 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 && (
+ }
+ onClick={this.handleDownloadCsv}
+ sx={{ ml: 'auto', borderRadius: 2, fontSize: '0.75rem', fontWeight: 600 }}
+ >
+ CSV herunterladen
+
+ )}
-
+
+ {/* 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 };
}
/**