439 lines
13 KiB
TypeScript
439 lines
13 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;
|
||
}
|
||
|
||
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';
|
||
|
||
// 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: board?.running ? (bridgeConnected ? '#4caf50' : '#f59e0b') : '#6b7280',
|
||
}}
|
||
/>
|
||
<span style={styles.statusLabel}>
|
||
{board?.running
|
||
? (bridgeConnected ? t('editor.pi.connected') : 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')}
|
||
>
|
||
▶ {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')}
|
||
>
|
||
⌨ {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}>🖥️</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)}>
|
||
▶ {t('editor.pi.startPi')}
|
||
</button>
|
||
<div style={styles.offlineNote}>
|
||
{t('editor.pi.offlineNote1')}
|
||
<br />
|
||
{t('editor.pi.offlineNote2')}
|
||
</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: {
|
||
fontSize: 40,
|
||
lineHeight: 1,
|
||
},
|
||
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,
|
||
},
|
||
};
|