Compare commits
2 Commits
f778b2d104
...
6af150aea4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af150aea4 | ||
|
|
9bc878eb51 |
@@ -7,7 +7,7 @@ description: >-
|
|||||||
|
|
||||||
# Dash API Routes Overview (All)
|
# Dash API Routes Overview (All)
|
||||||
|
|
||||||
Overview of all 206 API endpoints across all modules.
|
Overview of all 207 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### Notation
|
||||||
|
|
||||||
@@ -689,6 +689,9 @@ _none_
|
|||||||
#### Get a virtual filesystem file — `GET /vfs/{*path}`
|
#### Get a virtual filesystem file — `GET /vfs/{*path}`
|
||||||
- Params: `path: str, query: download?: bool, raw?: bool`
|
- Params: `path: str, query: download?: bool, raw?: bool`
|
||||||
- Returns: `200 str`
|
- 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}`
|
#### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}`
|
||||||
- Params: `path: str, body: { content: str, mime?: str }`
|
- Params: `path: str, body: { content: str, mime?: str }`
|
||||||
- Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }`
|
- Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }`
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
NAV_SURFACE,
|
NAV_SURFACE,
|
||||||
} from '../colors';
|
} from '../colors';
|
||||||
import { ChatFab, ChatDrawer } from './chat';
|
import { ChatFab, ChatDrawer } from './chat';
|
||||||
|
import PageMinimap from './PageMinimap';
|
||||||
|
|
||||||
|
|
||||||
const SIDEBAR_WIDTH = 144;
|
const SIDEBAR_WIDTH = 144;
|
||||||
@@ -322,6 +323,7 @@ export default function AppShell() {
|
|||||||
</Box>
|
</Box>
|
||||||
<ChatFab />
|
<ChatFab />
|
||||||
<ChatDrawer />
|
<ChatDrawer />
|
||||||
|
<PageMinimap />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
389
src/components/PageMinimap.jsx
Normal file
389
src/components/PageMinimap.jsx
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { matchPath, useLocation } from 'react-router-dom';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import MapRoundedIcon from '@mui/icons-material/MapRounded';
|
||||||
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
|
import { ARTICLE_TAB, readPreferredArticleTab } from './article/articleRoute';
|
||||||
|
|
||||||
|
const MINIMAP_WIDTH = 220;
|
||||||
|
const MINIMAP_MAX_HEIGHT = 300;
|
||||||
|
const REBUILD_DEBOUNCE_MS = 600;
|
||||||
|
/** Hide the minimap this long after the last scroll event. */
|
||||||
|
const IDLE_HIDE_MS = 1200;
|
||||||
|
|
||||||
|
/** Clamp a number into [min, max]. */
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floating whole-page minimap, centered at the bottom of the viewport.
|
||||||
|
*
|
||||||
|
* Renders a live scaled-down clone of `sourceSelector` (default `<main>`)
|
||||||
|
* with a viewport rectangle that tracks `window.scrollY`. Clicking or
|
||||||
|
* dragging inside the thumbnail jumps the page to that position.
|
||||||
|
*
|
||||||
|
* Only renders when the page is actually scrollable
|
||||||
|
* (`documentHeight > 1.3 * viewportHeight`).
|
||||||
|
*/
|
||||||
|
export default function PageMinimap({ sourceSelector = 'main' }) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [thumb, setThumb] = useState({ width: MINIMAP_WIDTH, height: 0, scale: 1 });
|
||||||
|
const [scrollActive, setScrollActive] = useState(false);
|
||||||
|
const [hovering, setHovering] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const thumbBodyRef = useRef(null);
|
||||||
|
const viewportRef = useRef(null);
|
||||||
|
const draggingRef = useRef(false);
|
||||||
|
const rebuildTimerRef = useRef(null);
|
||||||
|
const idleTimerRef = useRef(null);
|
||||||
|
const metricsRef = useRef({ docH: 1, winH: 1, thumbH: 1 });
|
||||||
|
|
||||||
|
// SEO tab only: /artikel/:sku/seo, or /artikel/:sku when the preferred
|
||||||
|
// default tab resolves to seo.
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const articleMatch = matchPath('/artikel/:sku/:tab?', pathname);
|
||||||
|
const isSeoPage = Boolean(
|
||||||
|
articleMatch &&
|
||||||
|
(articleMatch.params?.tab === ARTICLE_TAB.seo ||
|
||||||
|
(!articleMatch.params?.tab &&
|
||||||
|
readPreferredArticleTab() === ARTICLE_TAB.seo)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Move the viewport rectangle without re-rendering (called on scroll). */
|
||||||
|
const updateViewport = useCallback(() => {
|
||||||
|
const el = viewportRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const { docH, winH, thumbH } = metricsRef.current;
|
||||||
|
if (docH <= 0 || thumbH <= 0) return;
|
||||||
|
const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
|
||||||
|
const top = clamp((scrollY / docH) * thumbH, 0, thumbH);
|
||||||
|
const height = clamp((winH / docH) * thumbH, 12, thumbH);
|
||||||
|
el.style.top = `${clamp(top, 0, Math.max(0, thumbH - height))}px`;
|
||||||
|
el.style.height = `${height}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Scroll the window so the clicked thumbnail offset lands mid-viewport. */
|
||||||
|
const jumpToThumbOffset = useCallback((offsetY) => {
|
||||||
|
const { docH, winH, thumbH } = metricsRef.current;
|
||||||
|
if (thumbH <= 0) return;
|
||||||
|
const ratio = clamp(offsetY / thumbH, 0, 1);
|
||||||
|
const target = ratio * docH - winH / 2;
|
||||||
|
window.scrollTo({ top: clamp(target, 0, Math.max(0, docH - winH)), behavior: 'auto' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleThumbPointerDown = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
draggingRef.current = true;
|
||||||
|
setDragging(true);
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
jumpToThumbOffset(event.clientY - rect.top);
|
||||||
|
},
|
||||||
|
[jumpToThumbOffset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleThumbPointerMove = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (!draggingRef.current) return;
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
jumpToThumbOffset(event.clientY - rect.top);
|
||||||
|
},
|
||||||
|
[jumpToThumbOffset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const endDrag = useCallback(() => {
|
||||||
|
draggingRef.current = false;
|
||||||
|
setDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Rebuild the scaled clone and recompute geometry. */
|
||||||
|
const rebuild = useCallback(() => {
|
||||||
|
const source = document.querySelector(sourceSelector);
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
const docH = document.documentElement.scrollHeight || 1;
|
||||||
|
const winH = window.innerHeight || 1;
|
||||||
|
const sourceW = source.offsetWidth || source.getBoundingClientRect().width || 1;
|
||||||
|
|
||||||
|
const isLong = docH > winH * 1.3;
|
||||||
|
setEnabled((prev) => (prev === isLong ? prev : isLong));
|
||||||
|
if (!isLong) return;
|
||||||
|
|
||||||
|
// Host only exists once enabled+open render the thumbnail. Geometry is
|
||||||
|
// already stored above, so the enabled->render->rebuild pass clones it.
|
||||||
|
const host = thumbBodyRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
const scale = Math.min(MINIMAP_WIDTH / sourceW, MINIMAP_MAX_HEIGHT / docH);
|
||||||
|
const thumbW = Math.max(1, Math.round(sourceW * scale));
|
||||||
|
const thumbH = Math.max(1, Math.round(docH * scale));
|
||||||
|
metricsRef.current = { docH, winH, thumbH };
|
||||||
|
setThumb({ width: thumbW, height: thumbH, scale });
|
||||||
|
|
||||||
|
host.innerHTML = '';
|
||||||
|
const clone = source.cloneNode(true);
|
||||||
|
clone.removeAttribute('id');
|
||||||
|
clone.querySelectorAll('script, style, noscript').forEach((el) => el.remove());
|
||||||
|
clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
|
||||||
|
clone.querySelectorAll('a').forEach((el) => {
|
||||||
|
el.removeAttribute('href');
|
||||||
|
});
|
||||||
|
clone.setAttribute('aria-hidden', 'true');
|
||||||
|
clone.style.width = `${sourceW}px`;
|
||||||
|
clone.style.maxWidth = 'none';
|
||||||
|
clone.style.margin = '0';
|
||||||
|
clone.style.pointerEvents = 'none';
|
||||||
|
clone.style.userSelect = 'none';
|
||||||
|
clone.style.transform = `scale(${scale})`;
|
||||||
|
clone.style.transformOrigin = 'top left';
|
||||||
|
host.appendChild(clone);
|
||||||
|
|
||||||
|
requestAnimationFrame(updateViewport);
|
||||||
|
}, [sourceSelector, updateViewport]);
|
||||||
|
|
||||||
|
const scheduleRebuild = useCallback(() => {
|
||||||
|
if (rebuildTimerRef.current) clearTimeout(rebuildTimerRef.current);
|
||||||
|
rebuildTimerRef.current = setTimeout(() => {
|
||||||
|
rebuildTimerRef.current = null;
|
||||||
|
rebuild();
|
||||||
|
}, REBUILD_DEBOUNCE_MS);
|
||||||
|
}, [rebuild]);
|
||||||
|
|
||||||
|
// Second pass: first rebuild() only flips `enabled` on (no host yet);
|
||||||
|
// once the thumbnail mounts, rebuild again to actually clone content.
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled && open) rebuild();
|
||||||
|
}, [enabled, open, rebuild]);
|
||||||
|
|
||||||
|
// Reset scroll visibility on navigation; the new page starts hidden
|
||||||
|
// until the user scrolls again.
|
||||||
|
useEffect(() => {
|
||||||
|
setScrollActive(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rebuild();
|
||||||
|
// Article content loads async (automat cache), so re-check shortly
|
||||||
|
// after mount in case the page grows past the threshold late.
|
||||||
|
const t1 = setTimeout(rebuild, 500);
|
||||||
|
const t2 = setTimeout(rebuild, 1500);
|
||||||
|
let raf = 0;
|
||||||
|
const onScroll = () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
raf = requestAnimationFrame(updateViewport);
|
||||||
|
// Show while scrolling; hide ~1s after the last scroll event.
|
||||||
|
setScrollActive(true);
|
||||||
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||||
|
idleTimerRef.current = setTimeout(() => {
|
||||||
|
idleTimerRef.current = null;
|
||||||
|
setScrollActive(false);
|
||||||
|
}, IDLE_HIDE_MS);
|
||||||
|
};
|
||||||
|
const onResize = () => rebuild();
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
const source = document.querySelector(sourceSelector);
|
||||||
|
const observer = new MutationObserver(scheduleRebuild);
|
||||||
|
if (source) {
|
||||||
|
observer.observe(source, { childList: true, subtree: true, characterData: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
clearTimeout(t1);
|
||||||
|
clearTimeout(t2);
|
||||||
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||||
|
window.removeEventListener('scroll', onScroll);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
observer.disconnect();
|
||||||
|
if (rebuildTimerRef.current) clearTimeout(rebuildTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [rebuild, scheduleRebuild, sourceSelector, updateViewport]);
|
||||||
|
|
||||||
|
// Keep the viewport rect in sync when the thumbnail geometry changes.
|
||||||
|
useEffect(() => {
|
||||||
|
updateViewport();
|
||||||
|
}, [thumb, open, updateViewport]);
|
||||||
|
|
||||||
|
// Stays mounted for fade-out; hidden unless scrolling/hovering/dragging.
|
||||||
|
const visible = scrollActive || hovering || dragging;
|
||||||
|
|
||||||
|
if (!enabled || !isSeoPage) return null;
|
||||||
|
|
||||||
|
// Collapsed: a single centered pill to reopen the overview.
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: '50%',
|
||||||
|
bottom: 16,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1300,
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transition: 'opacity 300ms ease',
|
||||||
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
aria-label="Seiten-Übersicht anzeigen"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
sx={(theme) => ({
|
||||||
|
bgcolor: theme.palette.background.paper,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: theme.palette.divider,
|
||||||
|
boxShadow: theme.shadows[6],
|
||||||
|
opacity: 0.85,
|
||||||
|
'&:hover': { opacity: 1 },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<MapRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: '50%',
|
||||||
|
bottom: 16,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1300,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transition: 'opacity 300ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onMouseEnter={() => setHovering(true)}
|
||||||
|
onMouseLeave={() => setHovering(false)}
|
||||||
|
sx={(theme) => ({
|
||||||
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: theme.palette.divider,
|
||||||
|
bgcolor: theme.palette.background.paper,
|
||||||
|
boxShadow: theme.shadows[8],
|
||||||
|
opacity: 0.96,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
px: 1,
|
||||||
|
py: 0.5,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
aria-label="Seiten-Übersicht schließen"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
sx={{ p: 0.25 }}
|
||||||
|
>
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ p: 1, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Box
|
||||||
|
role="slider"
|
||||||
|
aria-label="Seitenposition"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerDown={handleThumbPointerDown}
|
||||||
|
onPointerMove={handleThumbPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
const { docH, winH } = metricsRef.current;
|
||||||
|
const y = window.scrollY || 0;
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
window.scrollTo({ top: y + winH * 0.8 });
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
window.scrollTo({ top: y - winH * 0.8 });
|
||||||
|
} else if (event.key === 'Home') {
|
||||||
|
event.preventDefault();
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
} else if (event.key === 'End') {
|
||||||
|
event.preventDefault();
|
||||||
|
window.scrollTo({ top: docH });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={(theme) => ({
|
||||||
|
position: 'relative',
|
||||||
|
width: thumb.width,
|
||||||
|
height: thumb.height,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: theme.palette.divider,
|
||||||
|
bgcolor: theme.palette.action.hover,
|
||||||
|
cursor: 'pointer',
|
||||||
|
outline: 'none',
|
||||||
|
touchAction: 'none',
|
||||||
|
'&:focus-visible': {
|
||||||
|
borderColor: theme.palette.primary.main,
|
||||||
|
boxShadow: `0 0 0 2px ${theme.palette.primary.main}55`,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
ref={thumbBodyRef}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: thumb.width,
|
||||||
|
height: thumb.height,
|
||||||
|
overflow: 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
ref={viewportRef}
|
||||||
|
sx={(theme) => ({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: 24,
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: theme.palette.primary.main,
|
||||||
|
bgcolor: `${theme.palette.primary.main}22`,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 VfsFileExplorer from './VfsFileExplorer';
|
||||||
import VfsTextEditor from './VfsTextEditor';
|
import VfsTextEditor from './VfsTextEditor';
|
||||||
import VfsFilePreview from './VfsFilePreview';
|
import VfsFilePreview from './VfsFilePreview';
|
||||||
|
import VfsAppletRunner from './VfsAppletRunner';
|
||||||
import VfsNewFileDialog from './VfsNewFileDialog';
|
import VfsNewFileDialog from './VfsNewFileDialog';
|
||||||
|
import { isAppletFile } from './vfsUtils';
|
||||||
|
|
||||||
export default class VfsEditorPanel extends PureComponent {
|
export default class VfsEditorPanel extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -31,6 +33,8 @@ export default class VfsEditorPanel extends PureComponent {
|
|||||||
searchQuery: s.searchQuery,
|
searchQuery: s.searchQuery,
|
||||||
newFileDialogOpen: s.newFileDialogOpen,
|
newFileDialogOpen: s.newFileDialogOpen,
|
||||||
newFileDefaultFolder: s.newFileDefaultFolder,
|
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() {
|
componentWillUnmount() {
|
||||||
this.unsub?.();
|
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() {
|
render() {
|
||||||
const {
|
const {
|
||||||
files,
|
files,
|
||||||
@@ -100,8 +120,12 @@ export default class VfsEditorPanel extends PureComponent {
|
|||||||
saveError,
|
saveError,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
newFileDialogOpen,
|
newFileDialogOpen,
|
||||||
|
appletView,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
|
const isApplet = Boolean(selectedFileName && isAppletFile(selectedFileName));
|
||||||
|
const showAppletRunner = isApplet && selectedFile?.isText && appletView === 'run';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -115,7 +139,18 @@ export default class VfsEditorPanel extends PureComponent {
|
|||||||
borderColor: 'divider',
|
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 */
|
/* Single Column Mode: Text Editor */
|
||||||
<VfsTextEditor
|
<VfsTextEditor
|
||||||
fileName={selectedFileName}
|
fileName={selectedFileName}
|
||||||
@@ -131,6 +166,9 @@ export default class VfsEditorPanel extends PureComponent {
|
|||||||
onClose={() => vfsAutomat.actions.selectFile(null)}
|
onClose={() => vfsAutomat.actions.selectFile(null)}
|
||||||
onDownload={() => vfsAutomat.actions.downloadFile(selectedFileName)}
|
onDownload={() => vfsAutomat.actions.downloadFile(selectedFileName)}
|
||||||
onMentionInChat={(lineInfo) => this.handleMentionInChat(selectedFile, lineInfo)}
|
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 ? (
|
) : selectedFileName && !selectedFile?.isText ? (
|
||||||
/* Single Column Mode: Preview (Image, PDF, Binary) */
|
/* Single Column Mode: Preview (Image, PDF, Binary) */
|
||||||
@@ -181,6 +219,7 @@ export default class VfsEditorPanel extends PureComponent {
|
|||||||
selectedFileName={selectedFileName}
|
selectedFileName={selectedFileName}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSelectFile={(name) => vfsAutomat.actions.selectFile(name)}
|
onSelectFile={(name) => vfsAutomat.actions.selectFile(name)}
|
||||||
|
onRunApplet={this.handleRunApplet}
|
||||||
onRefresh={() => vfsAutomat.actions.loadFiles()}
|
onRefresh={() => vfsAutomat.actions.loadFiles()}
|
||||||
onOpenNewDialog={(parentDir) => vfsAutomat.actions.setNewFileDialogOpen(true, parentDir)}
|
onOpenNewDialog={(parentDir) => vfsAutomat.actions.setNewFileDialogOpen(true, parentDir)}
|
||||||
onSearchChange={(q) => vfsAutomat.actions.setSearchQuery(q)}
|
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 ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||||
import CreateNewFolderRoundedIcon from '@mui/icons-material/CreateNewFolderRounded';
|
import CreateNewFolderRoundedIcon from '@mui/icons-material/CreateNewFolderRounded';
|
||||||
import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded';
|
import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded';
|
||||||
|
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
|
||||||
|
|
||||||
import VfsFileIcon from './VfsFileIcon';
|
import VfsFileIcon from './VfsFileIcon';
|
||||||
import { formatVfsSize, formatVfsDate } from './vfsUtils';
|
import { formatVfsSize, formatVfsDate, isAppletFile } from './vfsUtils';
|
||||||
import VfsNewFolderDialog from './VfsNewFolderDialog';
|
import VfsNewFolderDialog from './VfsNewFolderDialog';
|
||||||
import VfsRenameDialog from './VfsRenameDialog';
|
import VfsRenameDialog from './VfsRenameDialog';
|
||||||
|
|
||||||
@@ -415,8 +416,9 @@ export default class VfsFileExplorer extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderFileNode = (file, depth) => {
|
renderFileNode = (file, depth) => {
|
||||||
const { selectedFileName, onSelectFile } = this.props;
|
const { selectedFileName, onSelectFile, onRunApplet } = this.props;
|
||||||
const isSelected = file.path === selectedFileName;
|
const isSelected = file.path === selectedFileName;
|
||||||
|
const isApplet = isAppletFile(file.path);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -486,6 +488,23 @@ export default class VfsFileExplorer extends PureComponent {
|
|||||||
'&:hover': { opacity: 1 },
|
'&: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
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => this.handleOpenMenu(e, file.fileData)}
|
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 AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
||||||
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
|
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
|
||||||
import ArticleRoundedIcon from '@mui/icons-material/ArticleRounded';
|
import ArticleRoundedIcon from '@mui/icons-material/ArticleRounded';
|
||||||
|
import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded';
|
||||||
import { getVfsCategory } from './vfsUtils';
|
import { getVfsCategory } from './vfsUtils';
|
||||||
|
|
||||||
const CATEGORY_CONFIG = {
|
const CATEGORY_CONFIG = {
|
||||||
|
applet: {
|
||||||
|
icon: SmartToyRoundedIcon,
|
||||||
|
color: '#00897b', // Teal
|
||||||
|
label: 'Applet',
|
||||||
|
},
|
||||||
skill: {
|
skill: {
|
||||||
icon: AutoAwesomeRoundedIcon,
|
icon: AutoAwesomeRoundedIcon,
|
||||||
color: '#9c27b0', // Purple
|
color: '#9c27b0', // Purple
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import UndoRoundedIcon from '@mui/icons-material/UndoRounded';
|
|||||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||||
|
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
|
||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
|
|
||||||
import VfsFileIcon from './VfsFileIcon';
|
import VfsFileIcon from './VfsFileIcon';
|
||||||
@@ -153,6 +154,8 @@ export default class VfsTextEditor extends PureComponent {
|
|||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
onMentionInChat,
|
onMentionInChat,
|
||||||
|
isApplet,
|
||||||
|
onShowRunner,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const lineCount = (content || '').split('\n').length;
|
const lineCount = (content || '').split('\n').length;
|
||||||
@@ -211,6 +214,24 @@ export default class VfsTextEditor extends PureComponent {
|
|||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
<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 && (
|
{onMentionInChat && (
|
||||||
<IconButton size="small" onClick={this.handleMentionInChat} aria-label="In Chat erwähnen" sx={{ p: 0.6 }}>
|
<IconButton size="small" onClick={this.handleMentionInChat} aria-label="In Chat erwähnen" sx={{ p: 0.6 }}>
|
||||||
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
|
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export { default as VfsFileExplorer } from './VfsFileExplorer';
|
|||||||
export { default as VfsTextEditor } from './VfsTextEditor';
|
export { default as VfsTextEditor } from './VfsTextEditor';
|
||||||
export { default as VfsFilePreview } from './VfsFilePreview';
|
export { default as VfsFilePreview } from './VfsFilePreview';
|
||||||
export { default as VfsFileIcon } from './VfsFileIcon';
|
export { default as VfsFileIcon } from './VfsFileIcon';
|
||||||
|
export { default as VfsAppletForm } from './VfsAppletForm';
|
||||||
|
export { default as VfsAppletRunner } from './VfsAppletRunner';
|
||||||
export * from './vfsUtils';
|
export * from './vfsUtils';
|
||||||
|
|||||||
@@ -41,12 +41,13 @@ export function formatVfsDate(isoString) {
|
|||||||
* Detect category of file.
|
* Detect category of file.
|
||||||
* @param {string} filePath
|
* @param {string} filePath
|
||||||
* @param {string} [mime]
|
* @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 = '') {
|
export function getVfsCategory(filePath, mime = '') {
|
||||||
const p = String(filePath || '').toLowerCase();
|
const p = String(filePath || '').toLowerCase();
|
||||||
const m = String(mime || '').toLowerCase();
|
const m = String(mime || '').toLowerCase();
|
||||||
|
|
||||||
|
if (isAppletFile(p)) return 'applet';
|
||||||
if (p.endsWith('.skill')) return 'skill';
|
if (p.endsWith('.skill')) return 'skill';
|
||||||
if (p.endsWith('.pdf') || m === 'application/pdf') return 'pdf';
|
if (p.endsWith('.pdf') || m === 'application/pdf') return 'pdf';
|
||||||
if (
|
if (
|
||||||
@@ -79,6 +80,17 @@ export function getVfsCategory(filePath, mime = '') {
|
|||||||
return 'binary';
|
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.
|
* Split a VFS file path into directory and basename.
|
||||||
* @param {string} path
|
* @param {string} path
|
||||||
@@ -95,3 +107,77 @@ export function splitVfsPath(path) {
|
|||||||
base: clean.slice(lastSlash + 1),
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -607,6 +607,9 @@ class ArtikelSearchPage extends Component {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
// Match PanelTile (PANEL_TILE_BORDER_RADIUS: borderRadius 4 →
|
||||||
|
// theme.shape.borderRadius × 4 = 16px) so both columns line up.
|
||||||
|
borderRadius: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
Reference in New Issue
Block a user