u
This commit is contained in:
@@ -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 (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.8rem' }}>
|
||||
Kein @applet-output-schema im Dateikopf gefunden.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ overflowX: 'auto', border: 1, borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Box
|
||||
component="table"
|
||||
sx={{
|
||||
borderCollapse: 'collapse',
|
||||
width: '100%',
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
fontSize: '0.78rem',
|
||||
}}
|
||||
>
|
||||
<Box component="thead">
|
||||
<Box component="tr" sx={{ bgcolor: 'action.hover' }}>
|
||||
{columns.map((col) => (
|
||||
<Box
|
||||
component="th"
|
||||
key={col.name}
|
||||
sx={{
|
||||
textAlign: 'left',
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" fontWeight={700} sx={{ fontSize: '0.75rem' }}>
|
||||
{col.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
sx={{ display: 'block', fontSize: '0.65rem', fontWeight: 400 }}
|
||||
>
|
||||
{col.type}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{runRows.length === 0 ? (
|
||||
<Box component="tr">
|
||||
<Box
|
||||
component="td"
|
||||
colSpan={columns.length}
|
||||
sx={{ px: 1.25, py: 1.5, color: 'text.secondary', textAlign: 'center' }}
|
||||
>
|
||||
Noch nicht ausgeführt
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
runRows.map((row, rowIndex) => (
|
||||
<Box
|
||||
component="tr"
|
||||
key={rowIndex}
|
||||
sx={{ '&:nth-of-type(odd)': { bgcolor: 'action.hover' } }}
|
||||
>
|
||||
{columns.map((col, colIndex) => (
|
||||
<Box
|
||||
component="td"
|
||||
key={col.name}
|
||||
sx={{
|
||||
px: 1.25,
|
||||
py: 0.6,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{row[colIndex] ?? ''}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
@@ -143,47 +328,105 @@ export default class VfsAppletRunner extends PureComponent {
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Run Button (no-op for now) */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{/* Run Button */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayArrowRoundedIcon />}
|
||||
startIcon={isRunning ? <CircularProgress size={16} color="inherit" /> : <PlayArrowRoundedIcon />}
|
||||
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'}
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Führt das Applet noch nicht aus (nur Formular-Vorschau).
|
||||
</Typography>
|
||||
{durationMs != null && !isRunning && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{durationMs} ms
|
||||
</Typography>
|
||||
)}
|
||||
{truncated && (
|
||||
<Typography variant="caption" color="warning.main">
|
||||
stdout gekürzt
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Run error */}
|
||||
{runError && (
|
||||
<Alert severity="error" sx={{ fontSize: '0.8rem' }}>
|
||||
{runError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* stdout (only when the applet logged something) */}
|
||||
{stdout && (
|
||||
<Box>
|
||||
<Typography variant="caption" fontWeight={700} color="text.secondary">
|
||||
Konsole
|
||||
</Typography>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
m: 0,
|
||||
mt: 0.5,
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: (theme) => (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}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Output Section (read-only preview) */}
|
||||
{/* Output Section (typed CSV result table) */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<OutputRoundedIcon color="action" sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Ergebnis
|
||||
</Typography>
|
||||
{runResult && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<DownloadRoundedIcon />}
|
||||
onClick={this.handleDownloadCsv}
|
||||
sx={{ ml: 'auto', borderRadius: 2, fontSize: '0.75rem', fontWeight: 600 }}
|
||||
>
|
||||
CSV herunterladen
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<VfsAppletForm
|
||||
schema={outputSchema}
|
||||
values={outputValues}
|
||||
readOnly
|
||||
emptyHint="Kein @applet-output-schema im Dateikopf gefunden."
|
||||
/>
|
||||
|
||||
{/* Warnings: cells dropped for not matching their declared type. */}
|
||||
{runWarnings.length > 0 && (
|
||||
<Alert severity="warning" sx={{ fontSize: '0.78rem', mb: 1.5 }}>
|
||||
{runWarnings.map((warning) => (
|
||||
<div key={warning}>{warning}</div>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{this.renderResultTable()}
|
||||
</Box>
|
||||
|
||||
{!inputSchema && !outputSchema && (
|
||||
{!inputSchema && !outputColumns && (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||
In dieser Datei wurden keine Applet-Schemas gefunden. Erwartet werden Kopfzeilen wie
|
||||
<Box component="code" sx={{ display: 'block', mt: 0.5, fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
{'// @applet-input-schema {"type":"object","properties":{...}}'}
|
||||
<br />
|
||||
{'// @applet-output-schema {"type":"object","properties":{...}}'}
|
||||
{'// @applet-output-schema id:integer;name:string;active:boolean'}
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user