499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
/**
|
||
* RaspberryPiWorkspace — replaces the Monaco editor when a Raspberry Pi 3B
|
||
* board is active. Shows a VFS explorer on the left and either:
|
||
* - An xterm.js terminal (default), or
|
||
* - A Monaco editor for the selected file
|
||
* on the right.
|
||
*/
|
||
|
||
import React, { useState, lazy, Suspense, useEffect } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import Editor from '@monaco-editor/react';
|
||
import { VirtualFileSystem } from './VirtualFileSystem';
|
||
import { useVfsStore } from '../../store/useVfsStore';
|
||
import { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore';
|
||
import { attachSlavesFromCanvas } from '../../simulation/piSlaveScanner';
|
||
import { boardDisplayName } from '../../types/board';
|
||
|
||
// Lazy-load PiTerminal so @xterm/xterm is only bundled when needed
|
||
const PiTerminal = lazy(() => import('./PiTerminal').then((m) => ({ default: m.PiTerminal })));
|
||
|
||
interface RaspberryPiWorkspaceProps {
|
||
boardId: string;
|
||
}
|
||
|
||
interface OpenFile {
|
||
nodeId: string;
|
||
filename: string;
|
||
}
|
||
|
||
// Inline SVG icons (house style: no emoji). currentColor lets them inherit the
|
||
// surrounding text colour.
|
||
const PlayIcon: React.FC = () => (
|
||
<svg width="11" height="11" viewBox="0 0 10 10" aria-hidden="true" style={{ marginRight: 5, verticalAlign: -1 }}>
|
||
<path d="M2 1.2 L8.5 5 L2 8.8 Z" fill="currentColor" />
|
||
</svg>
|
||
);
|
||
|
||
const TerminalIcon: React.FC = () => (
|
||
<svg width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" style={{ marginRight: 4, verticalAlign: -1 }}>
|
||
<rect x="1" y="2.5" width="14" height="11" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||
<path d="M4 6 L6.5 8 L4 10" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||
<line x1="8" y1="10.3" x2="11.5" y2="10.3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||
</svg>
|
||
);
|
||
|
||
const MonitorIcon: React.FC = () => (
|
||
<svg width="40" height="40" viewBox="0 0 24 24" aria-hidden="true">
|
||
<rect x="2.5" y="3.5" width="19" height="13" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||
<line x1="9" y1="20.5" x2="15" y2="20.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||
<line x1="12" y1="16.5" x2="12" y2="20.5" stroke="currentColor" strokeWidth="1.5" />
|
||
</svg>
|
||
);
|
||
|
||
// Indeterminate spinner using SMIL (self-contained — no global CSS keyframes).
|
||
const BootSpinner: React.FC = () => (
|
||
<svg width="34" height="34" viewBox="0 0 44 44" aria-hidden="true">
|
||
<circle cx="22" cy="22" r="18" fill="none" stroke="rgba(255,255,255,0.12)" strokeWidth="4" />
|
||
<path d="M22 4 a18 18 0 0 1 18 18" fill="none" stroke="#4fc3f7" strokeWidth="4" strokeLinecap="round">
|
||
<animateTransform attributeName="transform" type="rotate" from="0 22 22" to="360 22 22" dur="0.9s" repeatCount="indefinite" />
|
||
</path>
|
||
</svg>
|
||
);
|
||
|
||
export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boardId }) => {
|
||
const { t } = useTranslation();
|
||
const [activePane, setActivePane] = useState<'terminal' | string>('terminal'); // string = nodeId
|
||
const [openFiles, setOpenFiles] = useState<OpenFile[]>([]);
|
||
const [bridgeConnected, setBridgeConnected] = useState(false);
|
||
|
||
const board = useSimulatorStore((s) => s.boards.find((b) => b.id === boardId));
|
||
const startBoard = useSimulatorStore((s) => s.startBoard);
|
||
const setContent = useVfsStore((s) => s.setContent);
|
||
const getNode = useVfsStore((s) => s.getNode);
|
||
|
||
// Display the active board's real name (Pi 3B / 4B / 5 / Zero / …) instead of
|
||
// a hardcoded "Raspberry Pi 3B" — the workspace serves the whole Pi family.
|
||
const boardLabel = board ? boardDisplayName(board) : 'Raspberry Pi';
|
||
|
||
// Three display states: offline (!running) → booting (running, guest Linux
|
||
// still coming up) → ready (running + booted shell). `running` flips on click
|
||
// but the guest takes 30-60s; `piBooted` is the real "shell ready" signal.
|
||
const booting = !!board?.running && !board?.piBooted;
|
||
const booted = !!board?.running && !!board?.piBooted;
|
||
|
||
// Auto-connect terminal when board starts running
|
||
useEffect(() => {
|
||
if (!board?.running) {
|
||
setBridgeConnected(false);
|
||
return;
|
||
}
|
||
// Small delay to let the bridge WebSocket establish after QEMU starts
|
||
const timer = setTimeout(() => {
|
||
const bridge = getBoardBridge(boardId);
|
||
if (bridge && !bridge.connected) {
|
||
bridge.connect();
|
||
}
|
||
setBridgeConnected(bridge?.connected ?? false);
|
||
|
||
// After the WS is open, scan the canvas for I2C/SPI/UART
|
||
// peripherals wired to this Pi and tell the backend to attach
|
||
// their slave models. We retry up to ~3s in case attachSlave
|
||
// calls race the WS open.
|
||
const attachOnce = (): boolean => {
|
||
const b = getBoardBridge(boardId);
|
||
if (!b?.connected) return false;
|
||
const { components, wires } = useSimulatorStore.getState();
|
||
attachSlavesFromCanvas(boardId, b, components, wires);
|
||
return true;
|
||
};
|
||
if (!attachOnce()) {
|
||
let attempts = 0;
|
||
const interval = setInterval(() => {
|
||
attempts++;
|
||
if (attachOnce() || attempts >= 6) clearInterval(interval);
|
||
}, 500);
|
||
}
|
||
}, 800);
|
||
return () => clearTimeout(timer);
|
||
}, [board?.running, boardId]);
|
||
|
||
// Poll bridge.connected state to reflect it in toolbar
|
||
useEffect(() => {
|
||
const interval = setInterval(() => {
|
||
const bridge = getBoardBridge(boardId);
|
||
setBridgeConnected(bridge?.connected ?? false);
|
||
}, 1000);
|
||
return () => clearInterval(interval);
|
||
}, [boardId]);
|
||
|
||
const handleFileSelect = (nodeId: string, _content: string, filename: string) => {
|
||
setOpenFiles((prev) => {
|
||
if (prev.find((f) => f.nodeId === nodeId)) return prev;
|
||
return [...prev, { nodeId, filename }];
|
||
});
|
||
setActivePane(nodeId);
|
||
};
|
||
|
||
const handleCloseFile = (nodeId: string) => {
|
||
setOpenFiles((prev) => prev.filter((f) => f.nodeId !== nodeId));
|
||
if (activePane === nodeId) setActivePane('terminal');
|
||
};
|
||
|
||
const handleConnect = () => {
|
||
const bridge = getBoardBridge(boardId);
|
||
if (bridge && !bridge.connected) {
|
||
bridge.connect();
|
||
setTimeout(() => setBridgeConnected(getBoardBridge(boardId)?.connected ?? false), 500);
|
||
}
|
||
};
|
||
|
||
const handleDisconnect = () => {
|
||
const bridge = getBoardBridge(boardId);
|
||
if (bridge && bridge.connected) {
|
||
bridge.disconnect();
|
||
setBridgeConnected(false);
|
||
}
|
||
};
|
||
|
||
const activeFileNode =
|
||
typeof activePane === 'string' && activePane !== 'terminal'
|
||
? getNode(boardId, activePane)
|
||
: null;
|
||
|
||
return (
|
||
<div style={styles.container}>
|
||
{/* Left: VFS explorer */}
|
||
<div style={styles.sidebar}>
|
||
<VirtualFileSystem boardId={boardId} onFileSelect={handleFileSelect} />
|
||
</div>
|
||
|
||
{/* Right: terminal or file editor */}
|
||
<div style={styles.main}>
|
||
{/* Pi-specific toolbar */}
|
||
<div style={styles.toolbar}>
|
||
<span style={styles.toolbarTitle}>{boardLabel}</span>
|
||
<div style={styles.toolbarActions}>
|
||
{/* Status indicator */}
|
||
<span
|
||
style={{
|
||
...styles.statusDot,
|
||
background: booted ? '#4caf50' : board?.running ? '#f59e0b' : '#6b7280',
|
||
}}
|
||
/>
|
||
<span style={styles.statusLabel}>
|
||
{booted
|
||
? t('editor.pi.connected')
|
||
: board?.running
|
||
? t('editor.pi.starting')
|
||
: t('editor.pi.offline')}
|
||
</span>
|
||
|
||
{!board?.running ? (
|
||
<button
|
||
style={{ ...styles.toolbarBtn, color: '#4caf50', borderColor: '#4caf50' }}
|
||
onClick={() => startBoard(boardId)}
|
||
title={t('editor.pi.powerOnTitle')}
|
||
>
|
||
<PlayIcon />{t('editor.pi.startPi')}
|
||
</button>
|
||
) : (
|
||
<>
|
||
<button
|
||
style={{ ...styles.toolbarBtn, color: bridgeConnected ? '#888' : '#4fc3f7' }}
|
||
onClick={handleConnect}
|
||
title={t('editor.pi.connectTitle')}
|
||
disabled={bridgeConnected}
|
||
>
|
||
{t('editor.pi.connect')}
|
||
</button>
|
||
<button
|
||
style={{ ...styles.toolbarBtn, color: '#ef9a9a' }}
|
||
onClick={handleDisconnect}
|
||
title={t('editor.pi.disconnectTitle')}
|
||
disabled={!bridgeConnected}
|
||
>
|
||
{t('editor.pi.disconnect')}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab strip */}
|
||
<div style={styles.tabStrip}>
|
||
{/* Terminal tab */}
|
||
<button
|
||
style={{
|
||
...styles.tab,
|
||
...(activePane === 'terminal' ? styles.tabActive : {}),
|
||
}}
|
||
onClick={() => setActivePane('terminal')}
|
||
>
|
||
<TerminalIcon />{t('editor.pi.terminal')}
|
||
</button>
|
||
|
||
{/* File tabs */}
|
||
{openFiles.map((f) => (
|
||
<button
|
||
key={f.nodeId}
|
||
style={{
|
||
...styles.tab,
|
||
...(activePane === f.nodeId ? styles.tabActive : {}),
|
||
}}
|
||
onClick={() => setActivePane(f.nodeId)}
|
||
>
|
||
{f.filename}
|
||
<span
|
||
style={styles.tabClose}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleCloseFile(f.nodeId);
|
||
}}
|
||
title={t('editor.pi.close')}
|
||
>
|
||
×
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Pane content */}
|
||
<div style={styles.pane}>
|
||
{/* Offline overlay — shown over terminal/editor when Pi is not running */}
|
||
{!board?.running && (
|
||
<div style={styles.offlineOverlay}>
|
||
<div style={styles.offlineBox}>
|
||
<div style={styles.offlineIcon}><MonitorIcon /></div>
|
||
<div style={styles.offlineTitle}>{t('editor.pi.offlineTitle', { board: boardLabel })}</div>
|
||
<div style={styles.offlineSubtitle}>{t('editor.pi.offlineSubtitle')}</div>
|
||
<button style={styles.startBtn} onClick={() => startBoard(boardId)}>
|
||
<PlayIcon />{t('editor.pi.startPi')}
|
||
</button>
|
||
<div style={styles.offlineNote}>
|
||
{t('editor.pi.offlineNote1')}
|
||
<br />
|
||
{t('editor.pi.offlineNote2')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Booting overlay — shown while the guest Linux comes up (~30-60s).
|
||
Without it the user clicks Start and sees nothing change for a
|
||
minute and assumes it is broken. */}
|
||
{booting && (
|
||
<div style={styles.offlineOverlay}>
|
||
<div style={styles.offlineBox}>
|
||
<div style={styles.bootingIcon}><BootSpinner /></div>
|
||
<div style={styles.offlineTitle}>{t('editor.pi.bootingTitle', { board: boardLabel })}</div>
|
||
<div style={styles.offlineSubtitle}>{t('editor.pi.bootingNote')}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{activePane === 'terminal' ? (
|
||
<Suspense fallback={<div style={styles.loading}>{t('editor.pi.loadingTerminal')}</div>}>
|
||
<PiTerminal boardId={boardId} />
|
||
</Suspense>
|
||
) : activeFileNode ? (
|
||
<Editor
|
||
key={activePane}
|
||
height="100%"
|
||
language={activeFileNode.name.endsWith('.py') ? 'python' : 'shell'}
|
||
theme="vs-dark"
|
||
value={activeFileNode.content ?? ''}
|
||
onChange={(val) => setContent(boardId, activePane, val ?? '')}
|
||
options={{
|
||
minimap: { enabled: false },
|
||
fontSize: 13,
|
||
automaticLayout: true,
|
||
scrollBeyondLastLine: false,
|
||
wordWrap: 'on',
|
||
}}
|
||
/>
|
||
) : (
|
||
<div style={styles.loading}>{t('editor.pi.selectFile')}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const styles: Record<string, React.CSSProperties> = {
|
||
container: {
|
||
display: 'flex',
|
||
width: '100%',
|
||
height: '100%',
|
||
overflow: 'hidden',
|
||
background: '#1e1e1e',
|
||
},
|
||
sidebar: {
|
||
width: 200,
|
||
minWidth: 160,
|
||
maxWidth: 280,
|
||
flexShrink: 0,
|
||
overflow: 'hidden',
|
||
borderRight: '1px solid #333',
|
||
},
|
||
main: {
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
minWidth: 0,
|
||
},
|
||
toolbar: {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
background: '#252526',
|
||
borderBottom: '1px solid #333',
|
||
padding: '0 10px',
|
||
height: 36,
|
||
flexShrink: 0,
|
||
},
|
||
toolbarTitle: {
|
||
color: '#ef9a9a',
|
||
fontSize: 11,
|
||
fontWeight: 700,
|
||
fontFamily: 'Segoe UI, sans-serif',
|
||
},
|
||
toolbarActions: {
|
||
display: 'flex',
|
||
gap: 6,
|
||
},
|
||
toolbarBtn: {
|
||
background: 'none',
|
||
border: '1px solid #3c3c3c',
|
||
borderRadius: 4,
|
||
cursor: 'pointer',
|
||
fontSize: 11,
|
||
padding: '2px 10px',
|
||
fontFamily: 'Segoe UI, sans-serif',
|
||
},
|
||
tabStrip: {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
background: '#252526',
|
||
borderBottom: '1px solid #333',
|
||
minHeight: 30,
|
||
flexShrink: 0,
|
||
overflow: 'hidden',
|
||
},
|
||
tab: {
|
||
background: 'none',
|
||
border: 'none',
|
||
borderBottom: '2px solid transparent',
|
||
color: '#999',
|
||
padding: '5px 12px',
|
||
cursor: 'pointer',
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
fontFamily: 'Segoe UI, sans-serif',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
whiteSpace: 'nowrap',
|
||
},
|
||
tabActive: {
|
||
borderBottomColor: '#ef9a9a',
|
||
color: '#ef9a9a',
|
||
background: 'rgba(255,255,255,0.04)',
|
||
},
|
||
tabClose: {
|
||
marginLeft: 4,
|
||
fontSize: 14,
|
||
lineHeight: 1,
|
||
opacity: 0.6,
|
||
cursor: 'pointer',
|
||
},
|
||
pane: {
|
||
flex: 1,
|
||
overflow: 'hidden',
|
||
minHeight: 0,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
position: 'relative',
|
||
},
|
||
loading: {
|
||
color: '#666',
|
||
fontSize: 12,
|
||
padding: 16,
|
||
fontFamily: 'Segoe UI, sans-serif',
|
||
},
|
||
statusDot: {
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: '50%',
|
||
flexShrink: 0,
|
||
},
|
||
statusLabel: {
|
||
color: '#aaa',
|
||
fontSize: 11,
|
||
fontFamily: 'Segoe UI, sans-serif',
|
||
marginRight: 6,
|
||
},
|
||
offlineOverlay: {
|
||
position: 'absolute' as const,
|
||
inset: 0,
|
||
background: 'rgba(10,10,10,0.88)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 10,
|
||
},
|
||
offlineBox: {
|
||
display: 'flex',
|
||
flexDirection: 'column' as const,
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
padding: '36px 40px',
|
||
background: '#1e1e1e',
|
||
border: '1px solid #444',
|
||
borderRadius: 10,
|
||
maxWidth: 360,
|
||
textAlign: 'center' as const,
|
||
},
|
||
offlineIcon: {
|
||
color: '#7a8290',
|
||
lineHeight: 0,
|
||
marginBottom: 2,
|
||
},
|
||
bootingIcon: {
|
||
lineHeight: 0,
|
||
marginBottom: 2,
|
||
},
|
||
offlineTitle: {
|
||
color: '#ef9a9a',
|
||
fontSize: 15,
|
||
fontWeight: 700,
|
||
fontFamily: 'Segoe UI, sans-serif',
|
||
},
|
||
offlineSubtitle: {
|
||
color: '#aaa',
|
||
fontSize: 12,
|
||
fontFamily: 'Segoe UI, sans-serif',
|
||
lineHeight: 1.5,
|
||
},
|
||
startBtn: {
|
||
background: '#1b5e20',
|
||
border: '1px solid #4caf50',
|
||
borderRadius: 6,
|
||
color: '#4caf50',
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
fontFamily: 'Segoe UI, sans-serif',
|
||
padding: '8px 24px',
|
||
cursor: 'pointer',
|
||
marginTop: 4,
|
||
},
|
||
offlineNote: {
|
||
color: '#666',
|
||
fontSize: 10,
|
||
fontFamily: 'Segoe UI, sans-serif',
|
||
lineHeight: 1.5,
|
||
marginTop: 4,
|
||
},
|
||
};
|