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:
parent
dd86020343
commit
bbafe66ee7
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore';
|
||||
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
||||
import { useSimulatorStore, piRerunScript } from '../../store/useSimulatorStore';
|
||||
import { useElectricalStore } from '../../store/useElectricalStore';
|
||||
import { type VerificationResult } from '../../simulation/verify/circuitVerifier';
|
||||
import { verifyCircuitFromStore } from '../../simulation/verify/verifyFromStore';
|
||||
|
|
@ -814,6 +814,33 @@ export const EditorToolbar = ({
|
|||
// QEMU boards: auto-compile if no firmware available yet
|
||||
if (isQemuBoard) {
|
||||
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()
|
||||
// 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
|
||||
|
|
@ -827,19 +854,6 @@ export const EditorToolbar = ({
|
|||
stopBoard(activeBoardId);
|
||||
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) {
|
||||
console.log('[handleRun] auto-compile + run');
|
||||
autoRunAfterCompile.current = true;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
DEFAULT_CHIP_PROGRAM_C,
|
||||
} from '../../services/romCompileService';
|
||||
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 { showMessageDialog, showConfirmDialog } from '../../store/useMessageDialogStore';
|
||||
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
|
||||
clear which board it belongs to. There is one per board.
|
||||
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
|
||||
className={`file-explorer-item fe-file-item${
|
||||
manifestViewBoardId === board.id ? ' file-explorer-item-active' : ''
|
||||
|
|
@ -796,6 +798,7 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
|
|||
{board.libraries?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { getBoardBridge } from '../../store/useSimulatorStore';
|
||||
import { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
interface PiTerminalProps {
|
||||
|
|
@ -58,6 +58,13 @@ export const PiTerminal: React.FC<PiTerminalProps> = ({ boardId }) => {
|
|||
termRef.current = term;
|
||||
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
|
||||
const onDataDispose = term.onData((data) => {
|
||||
const bridge = getBoardBridge(boardId);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import { useSimulatorStore } from '../../store/useSimulatorStore';
|
|||
import { getTabSessionId } from '../../simulation/Esp32Bridge';
|
||||
import { openDeviceGateway } from '../../lib/openDeviceGateway';
|
||||
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
|
||||
const BOARD_SHORT_LABEL: Partial<Record<string, string>> = {
|
||||
|
|
@ -252,7 +253,14 @@ export const SerialMonitor: React.FC = () => {
|
|||
</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}>
|
||||
{activeBoard?.serialOutput
|
||||
? (() => {
|
||||
|
|
@ -332,8 +340,10 @@ export const SerialMonitor: React.FC = () => {
|
|||
? t('editor.serial.waitingData') + '\n'
|
||||
: t('editor.serial.startSim') + '\n'}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Input row */}
|
||||
{/* Input row — the xterm handles Pi input itself */}
|
||||
{!(isPiBoardKind(activeBoard?.boardKind ?? '') && activeBoard?.running) && (
|
||||
<div style={styles.inputRow}>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -362,6 +372,7 @@ export const SerialMonitor: React.FC = () => {
|
|||
{t('editor.serial.send')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* 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 { startSimulation } from '../simulation/spice/start';
|
||||
import { useSEO } from '../utils/useSEO';
|
||||
|
|
@ -13,11 +13,6 @@ import { EditorToolbar } from '../components/editor/EditorToolbar';
|
|||
import { FileExplorer } from '../components/editor/FileExplorer';
|
||||
|
||||
// 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 { SimulatorCanvas } from '../components/simulator/SimulatorCanvas';
|
||||
import { SerialMonitor } from '../components/simulator/SerialMonitor';
|
||||
|
|
@ -33,7 +28,6 @@ import { useProjectStore } from '../store/useProjectStore';
|
|||
import { showConfirmDialog } from '../store/useMessageDialogStore';
|
||||
import { useAutoSaveProject } from '../hooks/useAutoSaveProject';
|
||||
import type { CompilationLog } from '../utils/compilationLogger';
|
||||
import { isPiBoardKind } from '../types/board';
|
||||
import '../App.css';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
|
@ -77,12 +71,6 @@ export const EditorPage: React.FC = () => {
|
|||
const resizingRef = useRef(false);
|
||||
const serialMonitorOpen = useSimulatorStore((s) => s.serialMonitorOpen);
|
||||
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 [consoleOpen, setConsoleOpen] = useState(false);
|
||||
// compileLogs live in a Zustand store so the velxio-pro agent overlay
|
||||
|
|
@ -599,21 +587,14 @@ export const EditorPage: React.FC = () => {
|
|||
</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 }}>
|
||||
{isLinuxPi && activeBoardId ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{ color: '#666', padding: 16, fontSize: 12 }}>
|
||||
Loading Pi workspace…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RaspberryPiWorkspace boardId={activeBoardId} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<CodeEditor />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Console */}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { create } from 'zustand';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { isPiBoardKind } from '../types/board';
|
||||
|
||||
export interface WorkspaceFile {
|
||||
id: string;
|
||||
|
|
@ -384,11 +385,13 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||
}));
|
||||
} else {
|
||||
// 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
|
||||
// (RP2040) is browser-emulated and not a Linux Pi. Mirrors
|
||||
// isPiBoardKind() in types/board.ts, adapted to the `group-<id>` convention.
|
||||
const isPi =
|
||||
groupId.includes('raspberry-pi-') && !groupId.includes('raspberry-pi-pico');
|
||||
// All QEMU-Linux boards (Pi Zero/1/2/3/4/5 plus overlay piFamily
|
||||
// kinds like the UNIHIKER) default to script.py. Group ids follow
|
||||
// `group-<boardId>` and the first board of a kind uses the kind as
|
||||
// its id ('-N' suffix for later instances), so strip both and ask
|
||||
// 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 mainId = `${groupId}-main`;
|
||||
let fileName: string;
|
||||
|
|
|
|||
|
|
@ -762,6 +762,36 @@ const stm32BridgeMap = new Map<string, Stm32Bridge>();
|
|||
export const getBoardSimulator = (id: string) => simulatorMap.get(id);
|
||||
export const getBoardPinManager = (id: string) => pinManagerMap.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 getStm32Bridge = (id: string) => stm32BridgeMap.get(id);
|
||||
|
||||
|
|
@ -1255,28 +1285,21 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
set((s) => ({
|
||||
boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)),
|
||||
}));
|
||||
// Overlay boards may declare autoRun: after boot (+setup) the VFS
|
||||
// is uploaded and the command executed, so one click on Run boots,
|
||||
// uploads and starts the user's script — same UX as every other
|
||||
// board's compile-and-run.
|
||||
const autoRun = async (): Promise<void> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
// After boot (+setup) the board's editor file group is uploaded
|
||||
// into the guest home and the run command executed, so one click
|
||||
// on Run boots, uploads and starts the user's script — same UX as
|
||||
// every other board's compile-and-run. Overlay boards can override
|
||||
// home/command via guestHome/autoRun.
|
||||
void (async () => {
|
||||
if (setup) {
|
||||
await bridge.sendAndWaitForPrompt(setup.endsWith('\n') ? setup : setup + '\n');
|
||||
}
|
||||
flip();
|
||||
await autoRun();
|
||||
try {
|
||||
await piSyncAndRunScript(id, boardKind);
|
||||
} catch (e) {
|
||||
console.warn(`[${boardKind}] auto-run failed:`, e);
|
||||
}
|
||||
})();
|
||||
};
|
||||
bridge.onDisconnected = () => {
|
||||
|
|
@ -1865,6 +1888,10 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
|
||||
if (isPiBoardKind(board.boardKind)) {
|
||||
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)) {
|
||||
// Pre-register sensors connected to this board so the QEMU worker
|
||||
// has them ready before the firmware starts executing.
|
||||
|
|
|
|||
|
|
@ -176,6 +176,18 @@ export async function loadExample(
|
|||
}
|
||||
|
||||
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 tree = vfsState.getTree(boardId);
|
||||
for (const [nodeId, node] of Object.entries(tree)) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue