feat(pi-family): unified editor UX — Monaco + explorer + bottom xterm, one file surface

QEMU-Linux boards used three competing file surfaces (workspace group
with a meaningless sketch.ino/libraries.json, the VFS panel with its
Upload button, and the Pi workspace's own editor). Now they behave like
every other board:

- the editor file group defaults to script.py for ANY Pi-family kind
  (kind-based check via isPiBoardKind, not the old raspberry-pi- string)
- the libraries.json manifest row is hidden for Pi boards
- EditorPage always renders Monaco; the RaspberryPiWorkspace swap is
  gone
- the bottom serial panel renders the interactive xterm (PiTerminal,
  now seeded with session history) for running Pi boards
- example vfsFiles load into the editor group (single source of truth);
  the run path uploads the group into the guest home
- Run on a booted guest re-runs without the 45 s reboot (Ctrl-C +
  re-upload + run); starting a Pi board pops the terminal open
- piSyncAndRunScript/piRerunScript exported from the store; auto-run
  now applies to the whole family (guestHome/autoRun overridable)
This commit is contained in:
David Montero Crespo 2026-07-28 19:43:20 +02:00
parent dd86020343
commit bbafe66ee7
8 changed files with 127 additions and 69 deletions

View File

@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore'; import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore';
import { useSimulatorStore } from '../../store/useSimulatorStore'; import { useSimulatorStore, piRerunScript } from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore'; import { useElectricalStore } from '../../store/useElectricalStore';
import { type VerificationResult } from '../../simulation/verify/circuitVerifier'; import { type VerificationResult } from '../../simulation/verify/circuitVerifier';
import { verifyCircuitFromStore } from '../../simulation/verify/verifyFromStore'; import { verifyCircuitFromStore } from '../../simulation/verify/verifyFromStore';
@ -814,6 +814,33 @@ export const EditorToolbar = ({
// QEMU boards: auto-compile if no firmware available yet // QEMU boards: auto-compile if no firmware available yet
if (isQemuBoard) { if (isQemuBoard) {
console.log('[handleRun] QEMU path'); console.log('[handleRun] QEMU path');
// QEMU-Linux boards (Raspberry Pi family + overlay piFamily kinds)
// boot straight from the rootfs — there is no firmware to compile,
// and handleCompile's Pi early-return never sets compiledProgram, so
// the gate below would surface a bogus "Compilation produced no
// firmware" error. Power the board on directly; if the guest is
// ALREADY booted, skip the ~45 s reboot: interrupt the running
// script, re-upload the edited files and run again. This branch must
// stay ABOVE the generic stop-then-boot restart below, which would
// otherwise power-cycle the live guest.
if (isPiBoardKind(board?.boardKind ?? '')) {
trackRunSimulation(board?.boardKind);
reportRun(board?.boardKind);
if (board?.running && board?.piBooted) {
console.log('[handleRun] → piRerunScript (booted, no reboot)', activeBoardId);
await piRerunScript(activeBoardId, board.boardKind);
} else {
if (board?.running) {
// Running but never reached the shell (stuck/zombie): power-cycle.
stopBoard(activeBoardId);
await new Promise((resolve) => setTimeout(resolve, 300));
}
console.log('[handleRun] → startBoard (QEMU-Linux, no firmware)', activeBoardId);
startBoard(activeBoardId);
}
setMessage(null);
return;
}
// Clean restart when the board is already running. Esp32Bridge.connect() // Clean restart when the board is already running. Esp32Bridge.connect()
// is a no-op while the socket is non-CLOSED, so startBoard() on a live // is a no-op while the socket is non-CLOSED, so startBoard() on a live
// session does NOTHING — and if the backend QEMU session has since died // session does NOTHING — and if the backend QEMU session has since died
@ -827,19 +854,6 @@ export const EditorToolbar = ({
stopBoard(activeBoardId); stopBoard(activeBoardId);
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
} }
// QEMU-Linux boards (Raspberry Pi family + overlay piFamily kinds)
// boot straight from the rootfs — there is no firmware to compile,
// and handleCompile's Pi early-return never sets compiledProgram, so
// the gate below would surface a bogus "Compilation produced no
// firmware" error. Power the board on directly instead.
if (isPiBoardKind(board?.boardKind ?? '')) {
trackRunSimulation(board?.boardKind);
reportRun(board?.boardKind);
console.log('[handleRun] → startBoard (QEMU-Linux, no firmware)', activeBoardId);
startBoard(activeBoardId);
setMessage(null);
return;
}
if (!board?.compiledProgram || codeChangedSinceLastCompile) { if (!board?.compiledProgram || codeChangedSinceLastCompile) {
console.log('[handleRun] auto-compile + run'); console.log('[handleRun] auto-compile + run');
autoRunAfterCompile.current = true; autoRunAfterCompile.current = true;

View File

@ -9,7 +9,7 @@ import {
DEFAULT_CHIP_PROGRAM_C, DEFAULT_CHIP_PROGRAM_C,
} from '../../services/romCompileService'; } from '../../services/romCompileService';
import type { BoardKind } from '../../types/board'; import type { BoardKind } from '../../types/board';
import { boardDisplayName } from '../../types/board'; import { boardDisplayName, isPiBoardKind } from '../../types/board';
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject'; import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
import { showMessageDialog, showConfirmDialog } from '../../store/useMessageDialogStore'; import { showMessageDialog, showConfirmDialog } from '../../store/useMessageDialogStore';
import './FileExplorer.css'; import './FileExplorer.css';
@ -761,7 +761,9 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
(compile scope), grouped with the board's code so it is (compile scope), grouped with the board's code so it is
clear which board it belongs to. There is one per board. clear which board it belongs to. There is one per board.
Clicking switches to the board and opens the Library Clicking switches to the board and opens the Library
Manager on its list. */} Manager on its list. QEMU-Linux boards run Python in a
guest OS no arduino-cli manifest, so no row. */}
{!isPiBoardKind(board.boardKind) && (
<div <div
className={`file-explorer-item fe-file-item${ className={`file-explorer-item fe-file-item${
manifestViewBoardId === board.id ? ' file-explorer-item-active' : '' manifestViewBoardId === board.id ? ' file-explorer-item-active' : ''
@ -796,6 +798,7 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
{board.libraries?.length ?? 0} {board.libraries?.length ?? 0}
</span> </span>
</div> </div>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -7,7 +7,7 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit'; import { FitAddon } from '@xterm/addon-fit';
import { getBoardBridge } from '../../store/useSimulatorStore'; import { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
interface PiTerminalProps { interface PiTerminalProps {
@ -58,6 +58,13 @@ export const PiTerminal: React.FC<PiTerminalProps> = ({ boardId }) => {
termRef.current = term; termRef.current = term;
fitAddonRef.current = fitAddon; fitAddonRef.current = fitAddon;
// Seed with the session's accumulated output so mounting mid-boot (the
// panel opens on demand) shows the boot log instead of a blank screen.
const history = useSimulatorStore
.getState()
.boards.find((b) => b.id === boardId)?.serialOutput;
if (history) term.write(history.slice(-8000));
// Wire terminal input → Pi bridge // Wire terminal input → Pi bridge
const onDataDispose = term.onData((data) => { const onDataDispose = term.onData((data) => {
const bridge = getBoardBridge(boardId); const bridge = getBoardBridge(boardId);

View File

@ -9,7 +9,8 @@ import { useSimulatorStore } from '../../store/useSimulatorStore';
import { getTabSessionId } from '../../simulation/Esp32Bridge'; import { getTabSessionId } from '../../simulation/Esp32Bridge';
import { openDeviceGateway } from '../../lib/openDeviceGateway'; import { openDeviceGateway } from '../../lib/openDeviceGateway';
import type { BoardKind } from '../../types/board'; import type { BoardKind } from '../../types/board';
import { boardDisplayName } from '../../types/board'; import { boardDisplayName, isPiBoardKind } from '../../types/board';
import { PiTerminal } from '../raspberry-pi/PiTerminal';
// Short labels for tabs // Short labels for tabs
const BOARD_SHORT_LABEL: Partial<Record<string, string>> = { const BOARD_SHORT_LABEL: Partial<Record<string, string>> = {
@ -252,7 +253,14 @@ export const SerialMonitor: React.FC = () => {
</div> </div>
</div> </div>
{/* Output area */} {/* Output area. QEMU-Linux boards get the interactive xterm (shell
input, line editing, ANSI) instead of the read-only mirror this
replaced the separate RaspberryPiWorkspace as the one terminal. */}
{isPiBoardKind(activeBoard?.boardKind ?? '') && activeBoard?.running ? (
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<PiTerminal key={activeBoard.id} boardId={activeBoard.id} />
</div>
) : (
<pre ref={outputRef} style={styles.output}> <pre ref={outputRef} style={styles.output}>
{activeBoard?.serialOutput {activeBoard?.serialOutput
? (() => { ? (() => {
@ -332,8 +340,10 @@ export const SerialMonitor: React.FC = () => {
? t('editor.serial.waitingData') + '\n' ? t('editor.serial.waitingData') + '\n'
: t('editor.serial.startSim') + '\n'} : t('editor.serial.startSim') + '\n'}
</pre> </pre>
)}
{/* Input row */} {/* Input row — the xterm handles Pi input itself */}
{!(isPiBoardKind(activeBoard?.boardKind ?? '') && activeBoard?.running) && (
<div style={styles.inputRow}> <div style={styles.inputRow}>
<input <input
type="text" type="text"
@ -362,6 +372,7 @@ export const SerialMonitor: React.FC = () => {
{t('editor.serial.send')} {t('editor.serial.send')}
</button> </button>
</div> </div>
)}
</div> </div>
); );
}; };

View File

@ -2,7 +2,7 @@
* Editor Page main editor + simulator with resizable panels * Editor Page main editor + simulator with resizable panels
*/ */
import React, { useRef, useState, useCallback, useEffect, lazy, Suspense } from 'react'; import React, { useRef, useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { startSimulation } from '../simulation/spice/start'; import { startSimulation } from '../simulation/spice/start';
import { useSEO } from '../utils/useSEO'; import { useSEO } from '../utils/useSEO';
@ -13,11 +13,6 @@ import { EditorToolbar } from '../components/editor/EditorToolbar';
import { FileExplorer } from '../components/editor/FileExplorer'; import { FileExplorer } from '../components/editor/FileExplorer';
// Lazy-load Pi workspace so xterm.js isn't in the main bundle // Lazy-load Pi workspace so xterm.js isn't in the main bundle
const RaspberryPiWorkspace = lazy(() =>
import('../components/raspberry-pi/RaspberryPiWorkspace').then((m) => ({
default: m.RaspberryPiWorkspace,
})),
);
import { CompilationConsole } from '../components/editor/CompilationConsole'; import { CompilationConsole } from '../components/editor/CompilationConsole';
import { SimulatorCanvas } from '../components/simulator/SimulatorCanvas'; import { SimulatorCanvas } from '../components/simulator/SimulatorCanvas';
import { SerialMonitor } from '../components/simulator/SerialMonitor'; import { SerialMonitor } from '../components/simulator/SerialMonitor';
@ -33,7 +28,6 @@ import { useProjectStore } from '../store/useProjectStore';
import { showConfirmDialog } from '../store/useMessageDialogStore'; import { showConfirmDialog } from '../store/useMessageDialogStore';
import { useAutoSaveProject } from '../hooks/useAutoSaveProject'; import { useAutoSaveProject } from '../hooks/useAutoSaveProject';
import type { CompilationLog } from '../utils/compilationLogger'; import type { CompilationLog } from '../utils/compilationLogger';
import { isPiBoardKind } from '../types/board';
import '../App.css'; import '../App.css';
const MOBILE_BREAKPOINT = 768; const MOBILE_BREAKPOINT = 768;
@ -77,12 +71,6 @@ export const EditorPage: React.FC = () => {
const resizingRef = useRef(false); const resizingRef = useRef(false);
const serialMonitorOpen = useSimulatorStore((s) => s.serialMonitorOpen); const serialMonitorOpen = useSimulatorStore((s) => s.serialMonitorOpen);
const activeBoardId = useSimulatorStore((s) => s.activeBoardId); const activeBoardId = useSimulatorStore((s) => s.activeBoardId);
const activeBoardKind = useSimulatorStore(
(s) => s.boards.find((b) => b.id === s.activeBoardId)?.boardKind,
);
// Pi 3/4/5 and Zero/1/2 all run the QEMU Linux workspace (terminal + Python),
// not the Arduino/Monaco editor. Pico (RP2040) is browser-emulated, not a Pi here.
const isLinuxPi = isPiBoardKind(activeBoardKind ?? '');
const oscilloscopeOpen = useOscilloscopeStore((s) => s.open); const oscilloscopeOpen = useOscilloscopeStore((s) => s.open);
const [consoleOpen, setConsoleOpen] = useState(false); const [consoleOpen, setConsoleOpen] = useState(false);
// compileLogs live in a Zustand store so the velxio-pro agent overlay // compileLogs live in a Zustand store so the velxio-pro agent overlay
@ -599,21 +587,14 @@ export const EditorPage: React.FC = () => {
</div> </div>
)} )}
{/* Editor area: Pi workspace or Monaco editor */} {/* Editor area Monaco for every board, QEMU-Linux included.
Pi boards edit script.py here like any other board edits its
sketch; their interactive terminal lives in the bottom serial
panel (SerialMonitor renders an xterm for Pi kinds). The old
RaspberryPiWorkspace (own file tree + own editor + upload
button) confused users with three competing file surfaces. */}
<div className="editor-wrapper" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}> <div className="editor-wrapper" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{isLinuxPi && activeBoardId ? ( <CodeEditor />
<Suspense
fallback={
<div style={{ color: '#666', padding: 16, fontSize: 12 }}>
Loading Pi workspace
</div>
}
>
<RaspberryPiWorkspace boardId={activeBoardId} />
</Suspense>
) : (
<CodeEditor />
)}
</div> </div>
{/* Console */} {/* Console */}

View File

@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { isPiBoardKind } from '../types/board';
export interface WorkspaceFile { export interface WorkspaceFile {
id: string; id: string;
@ -384,11 +385,13 @@ export const useEditorStore = create<EditorState>((set, get) => ({
})); }));
} else { } else {
// Determine default file by group name convention or language mode. // Determine default file by group name convention or language mode.
// All Linux Pi boards (Zero/1/2/3/4/5) default to script.py; the Pico // All QEMU-Linux boards (Pi Zero/1/2/3/4/5 plus overlay piFamily
// (RP2040) is browser-emulated and not a Linux Pi. Mirrors // kinds like the UNIHIKER) default to script.py. Group ids follow
// isPiBoardKind() in types/board.ts, adapted to the `group-<id>` convention. // `group-<boardId>` and the first board of a kind uses the kind as
const isPi = // its id ('-N' suffix for later instances), so strip both and ask
groupId.includes('raspberry-pi-') && !groupId.includes('raspberry-pi-pico'); // isPiBoardKind — the same predicate every other Pi code path uses.
const boardIdPart = groupId.replace(/^group-/, '').replace(/-\d+$/, '');
const isPi = isPiBoardKind(boardIdPart);
const isMicroPython = languageMode === 'micropython'; const isMicroPython = languageMode === 'micropython';
const mainId = `${groupId}-main`; const mainId = `${groupId}-main`;
let fileName: string; let fileName: string;

View File

@ -762,6 +762,36 @@ const stm32BridgeMap = new Map<string, Stm32Bridge>();
export const getBoardSimulator = (id: string) => simulatorMap.get(id); export const getBoardSimulator = (id: string) => simulatorMap.get(id);
export const getBoardPinManager = (id: string) => pinManagerMap.get(id); export const getBoardPinManager = (id: string) => pinManagerMap.get(id);
export const getBoardBridge = (id: string) => bridgeMap.get(id); export const getBoardBridge = (id: string) => bridgeMap.get(id);
/** Upload a QEMU-Linux board's editor file group into the guest home and run
* its script (guestHome/autoRun overridable per overlay board). Used by the
* boot auto-run and by Run on an already-booted board. */
export async function piSyncAndRunScript(boardId: string, boardKind: string): Promise<void> {
const bridge = bridgeMap.get(boardId);
if (!bridge || !bridge.connected) return;
const proDef = getProBoard(boardKind);
const home = (proDef?.guestHome ?? '/home/pi').replace(/\/+$/, '');
const board = useSimulatorStore.getState().boards.find((b) => b.id === boardId);
const groupId = board?.activeFileGroupId ?? `group-${boardId}`;
const files = useEditorStore
.getState()
.getGroupFiles(groupId)
.map((f) => ({ path: `${home}/${f.name}`, content: f.content }));
const { uploadFilesToPi } = await import('../utils/piUpload');
await uploadFilesToPi(bridge, files);
const cmd = proDef?.autoRun ?? `python3 ${home}/script.py`;
bridge.sendSerialText(cmd.endsWith('\n') ? cmd : cmd + '\n');
}
/** Re-run on a BOOTED QEMU-Linux board without rebooting: interrupt the
* running script (Ctrl-C), re-upload the file group, run again. */
export async function piRerunScript(boardId: string, boardKind: string): Promise<void> {
const bridge = bridgeMap.get(boardId);
if (!bridge || !bridge.connected) return;
bridge.sendSerialBytes([0x03]);
await new Promise((r) => setTimeout(r, 400));
await piSyncAndRunScript(boardId, boardKind);
}
export const getEsp32Bridge = (id: string) => esp32BridgeMap.get(id); export const getEsp32Bridge = (id: string) => esp32BridgeMap.get(id);
export const getStm32Bridge = (id: string) => stm32BridgeMap.get(id); export const getStm32Bridge = (id: string) => stm32BridgeMap.get(id);
@ -1255,28 +1285,21 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
set((s) => ({ set((s) => ({
boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)), boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)),
})); }));
// Overlay boards may declare autoRun: after boot (+setup) the VFS // After boot (+setup) the board's editor file group is uploaded
// is uploaded and the command executed, so one click on Run boots, // into the guest home and the run command executed, so one click
// uploads and starts the user's script — same UX as every other // on Run boots, uploads and starts the user's script — same UX as
// board's compile-and-run. // every other board's compile-and-run. Overlay boards can override
const autoRun = async (): Promise<void> => { // home/command via guestHome/autoRun.
const cmd = proDef?.autoRun;
if (!cmd) return;
try {
const files = useVfsStore.getState().serializeForUpload(id);
const { uploadFilesToPi } = await import('../utils/piUpload');
await uploadFilesToPi(bridge, files);
bridge.sendSerialText(cmd.endsWith('\n') ? cmd : cmd + '\n');
} catch (e) {
console.warn(`[${boardKind}] autoRun failed:`, e);
}
};
void (async () => { void (async () => {
if (setup) { if (setup) {
await bridge.sendAndWaitForPrompt(setup.endsWith('\n') ? setup : setup + '\n'); await bridge.sendAndWaitForPrompt(setup.endsWith('\n') ? setup : setup + '\n');
} }
flip(); flip();
await autoRun(); try {
await piSyncAndRunScript(id, boardKind);
} catch (e) {
console.warn(`[${boardKind}] auto-run failed:`, e);
}
})(); })();
}; };
bridge.onDisconnected = () => { bridge.onDisconnected = () => {
@ -1865,6 +1888,10 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
if (isPiBoardKind(board.boardKind)) { if (isPiBoardKind(board.boardKind)) {
getBoardBridge(boardId)?.connect(); getBoardBridge(boardId)?.connect();
// Pop the terminal open so the user watches the guest boot and gets
// the interactive shell — without it a Linux board looks frozen for
// the ~45 s boot.
set({ serialMonitorOpen: true });
} else if (isEsp32Kind(board.boardKind)) { } else if (isEsp32Kind(board.boardKind)) {
// Pre-register sensors connected to this board so the QEMU worker // Pre-register sensors connected to this board so the QEMU worker
// has them ready before the firmware starts executing. // has them ready before the firmware starts executing.

View File

@ -176,6 +176,18 @@ export async function loadExample(
} }
if (eb.vfsFiles && isPiBoardKind(eb.boardKind)) { if (eb.vfsFiles && isPiBoardKind(eb.boardKind)) {
// Pi example scripts go into the board's REGULAR file group — they
// are edited in Monaco like any other board's code, and the run
// path uploads the group into the guest home. (The old separate
// VFS tree + panel + editor confused users with three file
// surfaces; the VFS store is still updated for back-compat with
// anything reading it, but the editor group is the source of truth.)
const groupFiles = Object.entries(eb.vfsFiles).map(([name, content]) => ({
name,
content,
}));
useEditorStore.getState().setActiveGroup(board.activeFileGroupId);
useEditorStore.getState().loadFiles(groupFiles);
const vfsState = useVfsStore.getState(); const vfsState = useVfsStore.getState();
const tree = vfsState.getTree(boardId); const tree = vfsState.getTree(boardId);
for (const [nodeId, node] of Object.entries(tree)) { for (const [nodeId, node] of Object.entries(tree)) {