/** * 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 = () => ( ); const TerminalIcon: React.FC = () => ( ); const MonitorIcon: React.FC = () => ( ); // Indeterminate spinner using SMIL (self-contained — no global CSS keyframes). const BootSpinner: React.FC = () => ( ); export const RaspberryPiWorkspace: React.FC = ({ boardId }) => { const { t } = useTranslation(); const [activePane, setActivePane] = useState<'terminal' | string>('terminal'); // string = nodeId const [openFiles, setOpenFiles] = useState([]); 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 (
{/* Left: VFS explorer */}
{/* Right: terminal or file editor */}
{/* Pi-specific toolbar */}
{boardLabel}
{/* Status indicator */} {booted ? t('editor.pi.connected') : board?.running ? t('editor.pi.starting') : t('editor.pi.offline')} {!board?.running ? ( ) : ( <> )}
{/* Tab strip */}
{/* Terminal tab */} {/* File tabs */} {openFiles.map((f) => ( ))}
{/* Pane content */}
{/* Offline overlay — shown over terminal/editor when Pi is not running */} {!board?.running && (
{t('editor.pi.offlineTitle', { board: boardLabel })}
{t('editor.pi.offlineSubtitle')}
{t('editor.pi.offlineNote1')}
{t('editor.pi.offlineNote2')}
)} {/* 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 && (
{t('editor.pi.bootingTitle', { board: boardLabel })}
{t('editor.pi.bootingNote')}
)} {activePane === 'terminal' ? ( {t('editor.pi.loadingTerminal')}
}> ) : activeFileNode ? ( setContent(boardId, activePane, val ?? '')} options={{ minimap: { enabled: false }, fontSize: 13, automaticLayout: true, scrollBeyondLastLine: false, wordWrap: 'on', }} /> ) : (
{t('editor.pi.selectFile')}
)}
); }; const styles: Record = { 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, }, };