u
This commit is contained in:
@@ -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 } }`
|
||||
|
||||
157
src/components/vfs/VfsAppletForm.jsx
Normal file
157
src/components/vfs/VfsAppletForm.jsx
Normal file
@@ -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 (
|
||||
<FormControlLabel
|
||||
key={key}
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={Boolean(raw)}
|
||||
disabled={Boolean(readOnly)}
|
||||
onChange={(e) => this.handleFieldChange(key, e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ fontSize: '0.82rem' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{prop?.description && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{prop.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
sx={{ m: 0, py: 0.25 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'number' || type === 'integer') {
|
||||
return (
|
||||
<TextField
|
||||
key={key}
|
||||
fullWidth
|
||||
size="small"
|
||||
type="number"
|
||||
label={label}
|
||||
value={raw === undefined || raw === null ? '' : raw}
|
||||
disabled={Boolean(readOnly)}
|
||||
helperText={prop?.description || (prop?.default !== undefined ? `Standard: ${String(prop.default)}` : '')}
|
||||
onChange={(e) => {
|
||||
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 (
|
||||
<TextField
|
||||
key={key}
|
||||
fullWidth
|
||||
size="small"
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
label={`${label} (JSON)`}
|
||||
value={text}
|
||||
disabled={Boolean(readOnly)}
|
||||
helperText={prop?.description || ''}
|
||||
onChange={(e) => 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 (
|
||||
<TextField
|
||||
key={key}
|
||||
fullWidth
|
||||
size="small"
|
||||
label={label}
|
||||
value={raw === undefined || raw === null ? '' : String(raw)}
|
||||
disabled={Boolean(readOnly)}
|
||||
helperText={prop?.description || (prop?.default !== undefined ? `Standard: ${String(prop.default)}` : '')}
|
||||
onChange={(e) => 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 (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.8rem' }}>
|
||||
{emptyHint || 'Keine Felder im Schema definiert.'}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{keys.map((key) => this.renderField(key, props[key]))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
196
src/components/vfs/VfsAppletRunner.jsx
Normal file
196
src/components/vfs/VfsAppletRunner.jsx
Normal file
@@ -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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Runner Header */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
px: 2,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
bgcolor: 'background.paper',
|
||||
flexShrink: 0,
|
||||
minHeight: 48,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0, mr: 1 }}>
|
||||
<IconButton size="small" onClick={onClose} aria-label="Zurück zur Dateiliste" sx={{ p: 0.5, mr: 0.25 }}>
|
||||
<ArrowBackRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<VfsFileIcon fileName={fileName} mime={file?.mime} fontSize="small" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={700}
|
||||
noWrap
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
>
|
||||
{fileName}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||
{onShowCode && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<CodeRoundedIcon />}
|
||||
onClick={onShowCode}
|
||||
sx={{ borderRadius: 2, fontSize: '0.78rem', fontWeight: 600 }}
|
||||
>
|
||||
Code
|
||||
</Button>
|
||||
)}
|
||||
{onMentionInChat && (
|
||||
<IconButton size="small" onClick={onMentionInChat} aria-label="In Chat erwähnen" sx={{ p: 0.6 }}>
|
||||
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton size="small" onClick={onClose} aria-label="Runner schließen" sx={{ p: 0.6, ml: 0.5 }}>
|
||||
<CloseRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Runner Content */}
|
||||
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{loadingContent ? (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1.5, color: 'text.secondary' }}>
|
||||
<CircularProgress size={24} />
|
||||
<Typography variant="body2">Lade Applet…</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{/* Input Section */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<InputRoundedIcon color="primary" sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Eingabe
|
||||
</Typography>
|
||||
</Box>
|
||||
<VfsAppletForm
|
||||
schema={inputSchema}
|
||||
values={inputValues}
|
||||
onChange={this.handleInputChange}
|
||||
emptyHint="Kein @applet-input-schema im Dateikopf gefunden."
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Run Button (no-op for now) */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayArrowRoundedIcon />}
|
||||
onClick={this.handleRun}
|
||||
title="Ausführung folgt in einem späteren Schritt"
|
||||
sx={{ borderRadius: 2, fontWeight: 700 }}
|
||||
>
|
||||
Ausführen
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Führt das Applet noch nicht aus (nur Formular-Vorschau).
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Output Section (read-only preview) */}
|
||||
<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>
|
||||
</Box>
|
||||
<VfsAppletForm
|
||||
schema={outputSchema}
|
||||
values={outputValues}
|
||||
readOnly
|
||||
emptyHint="Kein @applet-output-schema im Dateikopf gefunden."
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!inputSchema && !outputSchema && (
|
||||
<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":{...}}'}
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -115,7 +139,18 @@ export default class VfsEditorPanel extends PureComponent {
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{selectedFileName && selectedFile?.isText ? (
|
||||
{showAppletRunner ? (
|
||||
/* Applet Runner Mode: Input/Output forms + no-op Run button */
|
||||
<VfsAppletRunner
|
||||
fileName={selectedFileName}
|
||||
file={selectedFile}
|
||||
content={fileContent}
|
||||
loadingContent={loadingContent}
|
||||
onClose={() => vfsAutomat.actions.selectFile(null)}
|
||||
onShowCode={() => this.setState({ appletView: 'code' })}
|
||||
onMentionInChat={() => this.handleMentionInChat(selectedFile)}
|
||||
/>
|
||||
) : selectedFileName && selectedFile?.isText ? (
|
||||
/* Single Column Mode: Text Editor */
|
||||
<VfsTextEditor
|
||||
fileName={selectedFileName}
|
||||
@@ -131,6 +166,9 @@ export default class VfsEditorPanel extends PureComponent {
|
||||
onClose={() => 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)}
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
@@ -486,6 +488,23 @@ export default class VfsFileExplorer extends PureComponent {
|
||||
'&:hover': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
{isApplet && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onRunApplet) {
|
||||
onRunApplet(file.path);
|
||||
} else {
|
||||
onSelectFile(file.path);
|
||||
}
|
||||
}}
|
||||
aria-label="Applet ausf\u00fchren"
|
||||
sx={{ p: 0.5, color: 'success.main' }}
|
||||
>
|
||||
<PlayArrowRoundedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => this.handleOpenMenu(e, file.fileData)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||
{isApplet && onShowRunner && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="success"
|
||||
startIcon={<PlayArrowRoundedIcon />}
|
||||
onClick={onShowRunner}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 0.4,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Exe
|
||||
</Button>
|
||||
)}
|
||||
{onMentionInChat && (
|
||||
<IconButton size="small" onClick={this.handleMentionInChat} aria-label="In Chat erwähnen" sx={{ p: 0.6 }}>
|
||||
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user