u
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { vfs as vfsApi } from '../api';
|
||||
import { isAppletFile, parseAppletSchemas } from '../components/vfs/vfsUtils';
|
||||
import authAutomat from './authAutomat';
|
||||
import sseAutomat from './sseAutomat';
|
||||
|
||||
@@ -25,6 +26,7 @@ import sseAutomat from './sseAutomat';
|
||||
* @property {boolean} isText Whether the file can be edited in the text editor
|
||||
* @property {boolean} isImage Whether the file can be rendered as an image
|
||||
* @property {boolean} isPdf Whether the file is a PDF
|
||||
* @property {string} [description] Applet description declared in the @applet-description header
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -71,6 +73,7 @@ const initialState = {
|
||||
};
|
||||
|
||||
let vfsAutomat;
|
||||
const appletDescriptionCache = new Map();
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
@@ -120,7 +123,17 @@ const actions = {
|
||||
}
|
||||
try {
|
||||
const res = await vfsApi.list();
|
||||
const files = Array.isArray(res?.files) ? res.files : [];
|
||||
const rawFiles = Array.isArray(res?.files) ? res.files : [];
|
||||
const files = rawFiles.map((f) => {
|
||||
if (f.description) {
|
||||
appletDescriptionCache.set(f.name, f.description);
|
||||
return f;
|
||||
}
|
||||
if (appletDescriptionCache.has(f.name)) {
|
||||
return { ...f, description: appletDescriptionCache.get(f.name) };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
const currentSelected = vfsAutomat.state.selectedFileName;
|
||||
const updatedSelectedFile = currentSelected
|
||||
? files.find((f) => f.name === currentSelected) || null
|
||||
@@ -132,6 +145,10 @@ const actions = {
|
||||
loadingFiles: false,
|
||||
loadError: null,
|
||||
});
|
||||
|
||||
// Asynchronously fetch descriptions for any applet files that don't have one yet
|
||||
void actions.loadAppletDescriptions(files);
|
||||
|
||||
return files;
|
||||
} catch (err) {
|
||||
console.error('[vfsAutomat] loadFiles failed:', err);
|
||||
@@ -143,6 +160,68 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Asynchronously load description headers for applets missing cached descriptions.
|
||||
* @param {VfsFile[]} [files]
|
||||
*/
|
||||
async loadAppletDescriptions(files) {
|
||||
const list = files || vfsAutomat.state.files;
|
||||
const missing = list.filter((f) => isAppletFile(f.name) && !f.description);
|
||||
if (missing.length === 0) return;
|
||||
|
||||
let changed = false;
|
||||
await Promise.all(
|
||||
missing.map(async (file) => {
|
||||
try {
|
||||
const text = await vfsApi.getText(file.name);
|
||||
const { description } = parseAppletSchemas(text);
|
||||
if (description) {
|
||||
appletDescriptionCache.set(file.name, description);
|
||||
changed = true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore failures reading unreadable files
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (changed) {
|
||||
const nextFiles = vfsAutomat.state.files.map((f) => {
|
||||
const desc = appletDescriptionCache.get(f.name);
|
||||
return desc && f.description !== desc ? { ...f, description: desc } : f;
|
||||
});
|
||||
const currentSelected = vfsAutomat.state.selectedFileName;
|
||||
const updatedSelectedFile = currentSelected
|
||||
? nextFiles.find((f) => f.name === currentSelected) || null
|
||||
: null;
|
||||
vfsAutomat.setState({
|
||||
files: nextFiles,
|
||||
selectedFile: updatedSelectedFile,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Manually record or update an applet file's description in the state & cache.
|
||||
* @param {string} fileName
|
||||
* @param {string} description
|
||||
*/
|
||||
setAppletDescription(fileName, description) {
|
||||
if (!fileName || !description) return;
|
||||
appletDescriptionCache.set(fileName, description);
|
||||
const nextFiles = vfsAutomat.state.files.map((f) =>
|
||||
f.name === fileName ? { ...f, description } : f
|
||||
);
|
||||
const updatedSelectedFile =
|
||||
vfsAutomat.state.selectedFileName === fileName
|
||||
? { ...(vfsAutomat.state.selectedFile || {}), description }
|
||||
: vfsAutomat.state.selectedFile;
|
||||
vfsAutomat.setState({
|
||||
files: nextFiles,
|
||||
selectedFile: updatedSelectedFile,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Select a file for viewing or editing.
|
||||
* @param {string|null} fileName
|
||||
@@ -179,9 +258,18 @@ const actions = {
|
||||
const text = await vfsApi.getText(fileName);
|
||||
// Only apply if still selected
|
||||
if (vfsAutomat.state.selectedFileName === fileName) {
|
||||
let updatedFile = file;
|
||||
if (isAppletFile(fileName)) {
|
||||
const { description } = parseAppletSchemas(text);
|
||||
if (description) {
|
||||
appletDescriptionCache.set(fileName, description);
|
||||
updatedFile = { ...file, description };
|
||||
}
|
||||
}
|
||||
vfsAutomat.setState({
|
||||
fileContent: text,
|
||||
originalContent: text,
|
||||
selectedFile: updatedFile,
|
||||
isDirty: false,
|
||||
loadingContent: false,
|
||||
});
|
||||
@@ -224,6 +312,14 @@ const actions = {
|
||||
vfsAutomat.setState({ isSaving: true, saveError: null });
|
||||
try {
|
||||
await vfsApi.save(selectedFileName, fileContent, selectedFile?.mime);
|
||||
if (isAppletFile(selectedFileName)) {
|
||||
const { description } = parseAppletSchemas(fileContent);
|
||||
if (description) {
|
||||
appletDescriptionCache.set(selectedFileName, description);
|
||||
} else {
|
||||
appletDescriptionCache.delete(selectedFileName);
|
||||
}
|
||||
}
|
||||
vfsAutomat.setState({
|
||||
originalContent: fileContent,
|
||||
isDirty: false,
|
||||
@@ -274,6 +370,7 @@ const actions = {
|
||||
if (!fileName) return;
|
||||
try {
|
||||
await vfsApi.delete(fileName);
|
||||
appletDescriptionCache.delete(fileName);
|
||||
if (vfsAutomat.state.selectedFileName === fileName) {
|
||||
actions.selectFile(null);
|
||||
}
|
||||
@@ -320,6 +417,11 @@ const actions = {
|
||||
if (!cleanOld || !cleanNew) throw new Error('Alter und neuer Pfad dürfen nicht leer sein');
|
||||
try {
|
||||
const res = await vfsApi.rename(cleanOld, cleanNew);
|
||||
if (appletDescriptionCache.has(cleanOld)) {
|
||||
const desc = appletDescriptionCache.get(cleanOld);
|
||||
appletDescriptionCache.delete(cleanOld);
|
||||
appletDescriptionCache.set(cleanNew, desc);
|
||||
}
|
||||
const isCurrentSelected = vfsAutomat.state.selectedFileName === cleanOld;
|
||||
await actions.loadFiles(true);
|
||||
if (isCurrentSelected) {
|
||||
@@ -379,6 +481,7 @@ const actions = {
|
||||
* Reset state (on logout or company switch).
|
||||
*/
|
||||
reset() {
|
||||
appletDescriptionCache.clear();
|
||||
vfsAutomat.setState({
|
||||
files: [],
|
||||
loadingFiles: false,
|
||||
|
||||
@@ -31,10 +31,11 @@ import { vfs as vfsApi } from '../../api';
|
||||
export default class VfsAppletRunner extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { inputSchema, outputColumns } = parseAppletSchemas(props.content || '');
|
||||
const { inputSchema, outputColumns, description } = parseAppletSchemas(props.content || '');
|
||||
this.state = {
|
||||
inputSchema,
|
||||
outputColumns,
|
||||
description: description || props.file?.description || '',
|
||||
inputValues: buildAppletDefaults(inputSchema),
|
||||
isRunning: false,
|
||||
runError: null,
|
||||
@@ -50,10 +51,11 @@ export default class VfsAppletRunner extends PureComponent {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.content !== this.props.content || prevProps.fileName !== this.props.fileName) {
|
||||
const { inputSchema, outputColumns } = parseAppletSchemas(this.props.content || '');
|
||||
const { inputSchema, outputColumns, description } = parseAppletSchemas(this.props.content || '');
|
||||
this.setState({
|
||||
inputSchema,
|
||||
outputColumns,
|
||||
description: description || this.props.file?.description || '',
|
||||
inputValues: buildAppletDefaults(inputSchema),
|
||||
isRunning: false,
|
||||
runError: null,
|
||||
@@ -122,6 +124,7 @@ export default class VfsAppletRunner extends PureComponent {
|
||||
stdout: res?.stdout || '',
|
||||
durationMs: res?.durationMs ?? null,
|
||||
truncated: Boolean(res?.truncated),
|
||||
description: res?.description || this.state.description,
|
||||
});
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
@@ -236,6 +239,7 @@ export default class VfsAppletRunner extends PureComponent {
|
||||
const {
|
||||
inputSchema,
|
||||
outputColumns,
|
||||
description: stateDescription,
|
||||
inputValues,
|
||||
isRunning,
|
||||
runError,
|
||||
@@ -245,6 +249,7 @@ export default class VfsAppletRunner extends PureComponent {
|
||||
durationMs,
|
||||
truncated,
|
||||
} = this.state;
|
||||
const description = stateDescription || file?.description;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
@@ -268,14 +273,27 @@ export default class VfsAppletRunner extends PureComponent {
|
||||
<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 sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={700}
|
||||
noWrap
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
>
|
||||
{fileName}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
title={description}
|
||||
sx={{ display: 'block', fontSize: '0.72rem', lineHeight: 1.2 }}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||
|
||||
@@ -55,7 +55,11 @@ function buildFileTree(files, searchQuery = '') {
|
||||
const q = searchQuery ? searchQuery.trim().toLowerCase() : '';
|
||||
const filtered = (files || []).filter((f) => {
|
||||
if (!q) return true;
|
||||
return f.name.toLowerCase().includes(q) || (f.mime && f.mime.toLowerCase().includes(q));
|
||||
return (
|
||||
f.name.toLowerCase().includes(q) ||
|
||||
(f.mime && f.mime.toLowerCase().includes(q)) ||
|
||||
(f.description && f.description.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
const root = {
|
||||
@@ -457,25 +461,65 @@ export default class VfsFileExplorer extends PureComponent {
|
||||
/>
|
||||
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1, pr: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={isSelected ? 600 : 500}
|
||||
noWrap
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.82rem' }}
|
||||
>
|
||||
{file.name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mt: 0.15 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{formatVfsSize(file.fileData.size)}
|
||||
</Typography>
|
||||
{file.fileData.updated_at && (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.68rem' }}>
|
||||
{formatVfsDate(file.fileData.updated_at)}
|
||||
{file.fileData?.description ? (
|
||||
<>
|
||||
{/* Line 1: Description */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={isSelected ? 600 : 500}
|
||||
noWrap
|
||||
title={file.fileData.description}
|
||||
sx={{ fontSize: '0.82rem', lineHeight: 1.3 }}
|
||||
>
|
||||
{file.fileData.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Line 2: size, date, filename */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.15, minWidth: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem', flexShrink: 0 }}>
|
||||
{formatVfsSize(file.fileData.size)}
|
||||
</Typography>
|
||||
{file.fileData.updated_at && (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.68rem', flexShrink: 0 }}>
|
||||
{formatVfsDate(file.fileData.updated_at)}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
title={file.name}
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.68rem', minWidth: 0 }}
|
||||
>
|
||||
{file.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Line 1: filename */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={isSelected ? 600 : 500}
|
||||
noWrap
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.82rem' }}
|
||||
>
|
||||
{file.name}
|
||||
</Typography>
|
||||
|
||||
{/* Line 2: size, date */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mt: 0.15 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{formatVfsSize(file.fileData.size)}
|
||||
</Typography>
|
||||
{file.fileData.updated_at && (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.68rem' }}>
|
||||
{formatVfsDate(file.fileData.updated_at)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Quick action buttons on hover / select */}
|
||||
|
||||
@@ -208,15 +208,38 @@ export function parseCsvRows(text, separator = APPLET_CSV_SEPARATOR) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the applet description header.
|
||||
*
|
||||
* Accepts either a quoted string (e.g. `"Lorem ipsum"` or `'Lorem ipsum'`) or bare text.
|
||||
* Strips surrounding quotes if present and trims whitespace.
|
||||
*
|
||||
* @param {string} header Raw text after the `// @applet-description ` marker.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parseAppletDescription(header) {
|
||||
const text = String(header || '').trim();
|
||||
if (!text) return '';
|
||||
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed === 'string' && parsed.trim()) return parsed.trim();
|
||||
} catch {
|
||||
const unquoted = text.slice(1, -1).trim();
|
||||
if (unquoted) return unquoted;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Mirrors the backend's `readApplet()`: the input JSON Schema must be on line 1, the typed
|
||||
* CSV output columns on line 2, and the applet description on line 3.
|
||||
*
|
||||
* @param {string} content Applet file source.
|
||||
* @returns {{ inputSchema: object|null, outputColumns: Array<{name: string, type: string}>|null }}
|
||||
* @returns {{ inputSchema: object|null, outputColumns: Array<{name: string, type: string}>|null, description: string }}
|
||||
*/
|
||||
export function parseAppletSchemas(content) {
|
||||
const lines = String(content || '').replace(/^\uFEFF/, '').split(/\r?\n/);
|
||||
@@ -238,7 +261,15 @@ export function parseAppletSchemas(content) {
|
||||
outputColumns = parseTypedCsvColumns(outputMatch[1]);
|
||||
}
|
||||
|
||||
return { inputSchema, outputColumns };
|
||||
let description = '';
|
||||
const descMatch =
|
||||
lines[2]?.match(/^\/\/ @applet-description (.+)$/) ||
|
||||
lines.slice(0, 10).map((l) => l.match(/^\/\/ @applet-description (.+)$/)).find(Boolean);
|
||||
if (descMatch) {
|
||||
description = parseAppletDescription(descMatch[1]);
|
||||
}
|
||||
|
||||
return { inputSchema, outputColumns, description };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user