This commit is contained in:
seb
2026-10-12 14:30:26 +02:00
parent 627868eb2e
commit 15dce3c1cf
4 changed files with 231 additions and 35 deletions

View File

@@ -12,6 +12,7 @@
import { Automat } from 'react-automat'; import { Automat } from 'react-automat';
import { vfs as vfsApi } from '../api'; import { vfs as vfsApi } from '../api';
import { isAppletFile, parseAppletSchemas } from '../components/vfs/vfsUtils';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import sseAutomat from './sseAutomat'; 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} 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} isImage Whether the file can be rendered as an image
* @property {boolean} isPdf Whether the file is a PDF * @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; let vfsAutomat;
const appletDescriptionCache = new Map();
const actions = { const actions = {
/** /**
@@ -120,7 +123,17 @@ const actions = {
} }
try { try {
const res = await vfsApi.list(); 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 currentSelected = vfsAutomat.state.selectedFileName;
const updatedSelectedFile = currentSelected const updatedSelectedFile = currentSelected
? files.find((f) => f.name === currentSelected) || null ? files.find((f) => f.name === currentSelected) || null
@@ -132,6 +145,10 @@ const actions = {
loadingFiles: false, loadingFiles: false,
loadError: null, loadError: null,
}); });
// Asynchronously fetch descriptions for any applet files that don't have one yet
void actions.loadAppletDescriptions(files);
return files; return files;
} catch (err) { } catch (err) {
console.error('[vfsAutomat] loadFiles failed:', 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. * Select a file for viewing or editing.
* @param {string|null} fileName * @param {string|null} fileName
@@ -179,9 +258,18 @@ const actions = {
const text = await vfsApi.getText(fileName); const text = await vfsApi.getText(fileName);
// Only apply if still selected // Only apply if still selected
if (vfsAutomat.state.selectedFileName === fileName) { 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({ vfsAutomat.setState({
fileContent: text, fileContent: text,
originalContent: text, originalContent: text,
selectedFile: updatedFile,
isDirty: false, isDirty: false,
loadingContent: false, loadingContent: false,
}); });
@@ -224,6 +312,14 @@ const actions = {
vfsAutomat.setState({ isSaving: true, saveError: null }); vfsAutomat.setState({ isSaving: true, saveError: null });
try { try {
await vfsApi.save(selectedFileName, fileContent, selectedFile?.mime); 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({ vfsAutomat.setState({
originalContent: fileContent, originalContent: fileContent,
isDirty: false, isDirty: false,
@@ -274,6 +370,7 @@ const actions = {
if (!fileName) return; if (!fileName) return;
try { try {
await vfsApi.delete(fileName); await vfsApi.delete(fileName);
appletDescriptionCache.delete(fileName);
if (vfsAutomat.state.selectedFileName === fileName) { if (vfsAutomat.state.selectedFileName === fileName) {
actions.selectFile(null); actions.selectFile(null);
} }
@@ -320,6 +417,11 @@ const actions = {
if (!cleanOld || !cleanNew) throw new Error('Alter und neuer Pfad dürfen nicht leer sein'); if (!cleanOld || !cleanNew) throw new Error('Alter und neuer Pfad dürfen nicht leer sein');
try { try {
const res = await vfsApi.rename(cleanOld, cleanNew); 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; const isCurrentSelected = vfsAutomat.state.selectedFileName === cleanOld;
await actions.loadFiles(true); await actions.loadFiles(true);
if (isCurrentSelected) { if (isCurrentSelected) {
@@ -379,6 +481,7 @@ const actions = {
* Reset state (on logout or company switch). * Reset state (on logout or company switch).
*/ */
reset() { reset() {
appletDescriptionCache.clear();
vfsAutomat.setState({ vfsAutomat.setState({
files: [], files: [],
loadingFiles: false, loadingFiles: false,

View File

@@ -31,10 +31,11 @@ import { vfs as vfsApi } from '../../api';
export default class VfsAppletRunner extends PureComponent { export default class VfsAppletRunner extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const { inputSchema, outputColumns } = parseAppletSchemas(props.content || ''); const { inputSchema, outputColumns, description } = parseAppletSchemas(props.content || '');
this.state = { this.state = {
inputSchema, inputSchema,
outputColumns, outputColumns,
description: description || props.file?.description || '',
inputValues: buildAppletDefaults(inputSchema), inputValues: buildAppletDefaults(inputSchema),
isRunning: false, isRunning: false,
runError: null, runError: null,
@@ -50,10 +51,11 @@ export default class VfsAppletRunner extends PureComponent {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.content !== this.props.content || prevProps.fileName !== this.props.fileName) { 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({ this.setState({
inputSchema, inputSchema,
outputColumns, outputColumns,
description: description || this.props.file?.description || '',
inputValues: buildAppletDefaults(inputSchema), inputValues: buildAppletDefaults(inputSchema),
isRunning: false, isRunning: false,
runError: null, runError: null,
@@ -122,6 +124,7 @@ export default class VfsAppletRunner extends PureComponent {
stdout: res?.stdout || '', stdout: res?.stdout || '',
durationMs: res?.durationMs ?? null, durationMs: res?.durationMs ?? null,
truncated: Boolean(res?.truncated), truncated: Boolean(res?.truncated),
description: res?.description || this.state.description,
}); });
} catch (err) { } catch (err) {
this.setState({ this.setState({
@@ -236,6 +239,7 @@ export default class VfsAppletRunner extends PureComponent {
const { const {
inputSchema, inputSchema,
outputColumns, outputColumns,
description: stateDescription,
inputValues, inputValues,
isRunning, isRunning,
runError, runError,
@@ -245,6 +249,7 @@ export default class VfsAppletRunner extends PureComponent {
durationMs, durationMs,
truncated, truncated,
} = this.state; } = this.state;
const description = stateDescription || file?.description;
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -268,14 +273,27 @@ export default class VfsAppletRunner extends PureComponent {
<ArrowBackRoundedIcon fontSize="small" /> <ArrowBackRoundedIcon fontSize="small" />
</IconButton> </IconButton>
<VfsFileIcon fileName={fileName} mime={file?.mime} fontSize="small" /> <VfsFileIcon fileName={fileName} mime={file?.mime} fontSize="small" />
<Typography <Box sx={{ minWidth: 0 }}>
variant="body2" <Typography
fontWeight={700} variant="body2"
noWrap fontWeight={700}
sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }} noWrap
> sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
{fileName} >
</Typography> {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>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>

View File

@@ -55,7 +55,11 @@ function buildFileTree(files, searchQuery = '') {
const q = searchQuery ? searchQuery.trim().toLowerCase() : ''; const q = searchQuery ? searchQuery.trim().toLowerCase() : '';
const filtered = (files || []).filter((f) => { const filtered = (files || []).filter((f) => {
if (!q) return true; 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 = { const root = {
@@ -457,25 +461,65 @@ export default class VfsFileExplorer extends PureComponent {
/> />
<Box sx={{ minWidth: 0, flexGrow: 1, pr: 1 }}> <Box sx={{ minWidth: 0, flexGrow: 1, pr: 1 }}>
<Typography {file.fileData?.description ? (
variant="body2" <>
fontWeight={isSelected ? 600 : 500} {/* Line 1: Description */}
noWrap <Typography
sx={{ fontFamily: 'monospace', fontSize: '0.82rem' }} variant="body2"
> fontWeight={isSelected ? 600 : 500}
{file.name} noWrap
</Typography> title={file.fileData.description}
sx={{ fontSize: '0.82rem', lineHeight: 1.3 }}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mt: 0.15 }}> >
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}> {file.fileData.description}
{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> </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> </Box>
{/* Quick action buttons on hover / select */} {/* Quick action buttons on hover / select */}

View File

@@ -208,15 +208,38 @@ export function parseCsvRows(text, separator = APPLET_CSV_SEPARATOR) {
return rows; 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. * Parse applet metadata from the file header.
* *
* Mirrors the backend's `readApplet()`: the input JSON Schema must be on line 1 and the typed * Mirrors the backend's `readApplet()`: the input JSON Schema must be on line 1, the typed
* CSV output columns on line 2. Parsing the same two lines keeps the UI in lockstep with what * CSV output columns on line 2, and the applet description on line 3.
* the sandbox will actually accept.
* *
* @param {string} content Applet file source. * @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) { export function parseAppletSchemas(content) {
const lines = String(content || '').replace(/^\uFEFF/, '').split(/\r?\n/); const lines = String(content || '').replace(/^\uFEFF/, '').split(/\r?\n/);
@@ -238,7 +261,15 @@ export function parseAppletSchemas(content) {
outputColumns = parseTypedCsvColumns(outputMatch[1]); 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 };
} }
/** /**