velxio/frontend/src/components/editor/EditorToolbar.tsx

2012 lines
84 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore';
import { verifyCircuit, type VerificationResult } from '../../simulation/verify/circuitVerifier';
import { buildInputFromStore } from '../../simulation/spice/storeAdapter';
import { BOARD_PIN_GROUPS } from '../../simulation/spice/boardPinGroups';
import { CircuitVerificationModal } from '../simulator/CircuitVerificationModal';
import type { PinSourceState } from '../../simulation/spice/types';
import type { BoardKind, LanguageMode } from '../../types/board';
import { BOARD_KIND_FQBN, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, boardDisplayName } from '../../types/board';
import { compileCode } from '../../services/compilation';
import {
compileRom,
isChipProgramFile,
formatForFile,
targetForChip,
} from '../../services/romCompileService';
import { compileChip } from '../../services/chipCompileService';
import { clearChipDrives } from '../../simulation/customChips/chipPinDrives';
import { requestElectricalResolve } from '../../simulation/spice/electricalResolveHook';
import { reportRunEvent } from '../../services/metricsService';
import { useProjectStore } from '../../store/useProjectStore';
import { LibraryManagerModal } from '../simulator/LibraryManagerModal';
import { InstallLibrariesModal } from '../simulator/InstallLibrariesModal';
import { parseCompileResult } from '../../utils/compilationLogger';
import type { CompilationLog, CompileTarget } from '../../utils/compilationLogger';
import { exportToWokwiZip } from '../../utils/wokwiZip';
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
import { readFirmwareFile } from '../../utils/firmwareLoader';
import {
trackCompileCode,
trackRunSimulation,
trackStopSimulation,
trackResetSimulation,
trackOpenLibraryManager,
} from '../../utils/analytics';
import './EditorToolbar.css';
/**
* Output-console group for circuit pre-flight + runtime faults. Routing these
* into the compile console (instead of an inline toolbar toast that overlapped
* the Run/Stop buttons) gives one unified, red-coloured diagnostics log —
* Proteus-style. id is matched when clearing so the findings survive an
* auto-compile triggered by the same Run.
*/
const CIRCUIT_CHECK_TARGET: CompileTarget = {
id: 'circuit-check',
label: 'Circuit check',
kind: 'board',
};
/**
* Clear the output drives of every custom chip on the canvas and re-solve, so
* chip-driven LEDs go dark on Stop. A chip drives its nets via its own SPICE
* voltage sources (registered in chipPinDrives); stopBoard / electrical-pause
* don't touch those, so without this the LEDs would freeze at their last frame.
*/
function clearAllChipDrives(): void {
const comps = useSimulatorStore.getState().components;
let any = false;
for (const c of comps) {
if (c.metadataId === 'custom-chip') {
clearChipDrives(c.id);
any = true;
}
}
if (any) requestElectricalResolve();
}
/**
* Boards whose firmware runs in a QEMU worker rather than a client-side AVR
* core. They can start without a pre-stored `compiledProgram`. Shared by
* handleRun and handleRunAll so the two paths can't drift.
*/
function isQemuBoardKind(kind: BoardKind | undefined): boolean {
if (!kind) return false;
return (
isPiBoardKind(kind) ||
kind === 'esp32' ||
kind === 'esp32-s3' ||
kind === 'esp32-cam' ||
kind === 'esp32-c3' ||
kind === 'esp32-devkit-c-v4' ||
kind === 'wemos-lolin32-lite' ||
kind === 'xiao-esp32-s3' ||
kind === 'arduino-nano-esp32' ||
kind === 'xiao-esp32-c3' ||
kind === 'aitewinrobot-esp32c3-supermini'
);
}
interface EditorToolbarProps {
consoleOpen: boolean;
setConsoleOpen: (open: boolean | ((v: boolean) => boolean)) => void;
compileLogs: CompilationLog[];
setCompileLogs: (logs: CompilationLog[] | ((prev: CompilationLog[]) => CompilationLog[])) => void;
/**
* Optional element rendered between the left action group and the right
* action group. Normally empty (the slot just acts as a flexible spacer
* that keeps the right action icons pinned); private overlays may inject
* deployment-specific content here without forking the toolbar.
*/
centerSlot?: React.ReactNode;
/**
* Optional extra elements rendered after the built-in right-group buttons
* (Libraries / Import-Export / Output Console). Used by private overlays
* to add deployment-specific actions without forking the toolbar.
*/
rightSlot?: React.ReactNode;
}
const BOARD_PILL_ICON: Record<BoardKind, string> = {
'arduino-uno': '⬤',
'arduino-nano': '▪',
'arduino-mega': '▬',
'raspberry-pi-pico': '◆',
'raspberry-pi-3': '⬛',
'raspberry-pi-4': '⬛',
'raspberry-pi-5': '⬛',
esp32: '⬡',
'esp32-s3': '⬡',
'esp32-c3': '⬡',
'stm32-bluepill': '◈',
'stm32-blackpill': '◈',
'stm32-bluepill-f103cb': '◈',
'stm32-blackpill-f401': '◈',
'stm32-f4-discovery': '◈',
'stm32-olimex-h405': '◈',
'stm32-netduino-plus2': '◈',
'stm32-netduino2': '◈',
};
const BOARD_PILL_COLOR: Record<BoardKind, string> = {
'arduino-uno': '#4fc3f7',
'arduino-nano': '#4fc3f7',
'arduino-mega': '#4fc3f7',
'raspberry-pi-pico': '#ce93d8',
'raspberry-pi-3': '#ef9a9a',
'raspberry-pi-4': '#ef9a9a',
'raspberry-pi-5': '#ef9a9a',
esp32: '#a5d6a7',
'esp32-s3': '#a5d6a7',
'esp32-c3': '#a5d6a7',
'stm32-bluepill': '#80cbc4',
'stm32-blackpill': '#b0bec5',
'stm32-bluepill-f103cb': '#80cbc4',
'stm32-blackpill-f401': '#b0bec5',
'stm32-f4-discovery': '#90caf9',
'stm32-olimex-h405': '#a5d6a7',
'stm32-netduino-plus2': '#ce93d8',
'stm32-netduino2': '#ce93d8',
};
export const EditorToolbar = ({
consoleOpen,
setConsoleOpen,
compileLogs: _compileLogs,
setCompileLogs,
centerSlot,
rightSlot,
}: EditorToolbarProps) => {
const { t } = useTranslation();
const { files, codeChangedSinceLastCompile, markCompiled } = useEditorStore();
const {
boards,
activeBoardId,
compileBoardProgram,
loadMicroPythonProgram,
setBoardLanguageMode,
updateBoard,
startBoard,
stopBoard,
resetBoard,
// legacy compat
startSimulation,
stopSimulation,
resetSimulation,
running,
compiledHex,
} = useSimulatorStore();
const activeBoard = boards.find((b) => b.id === activeBoardId) ?? boards[0];
const currentProject = useProjectStore((s) => s.currentProject);
// Board-less mode: digital / analog SPICE-only circuits. The Run / Stop
// buttons toggle the SPICE solver's `paused` flag — pausing freezes every
// LED at its current brightness so the user can inspect the state, and
// resuming flushes the most recent switch toggle through the engine.
const electricalPaused = useElectricalStore((s) => s.paused);
const setElectricalPaused = useElectricalStore((s) => s.setPaused);
const isBoardless = boards.length === 0;
const digitalRunning = isBoardless && !electricalPaused;
// Any board actually running — the correct multi-target signal for the
// Run-All / Stop buttons (the flat `running` flag only tracks the ACTIVE
// board, so it misreports a multi-board or non-active-board run).
const anyBoardRunning = boards.some((b) => b.running);
// Multi-board: the primary Run button runs ALL boards (the whole wired
// project is one system — running a subset is almost never intended), with a
// split-menu to still run just the active board. Single-board is unchanged.
const isMultiBoard = boards.length > 1;
// A "run target" is a board OR a programmable custom-chip (a CPU that runs a
// ROM). When there is more than one target — two boards, a board + a chip, or
// several chips — the unified Compile-All / Run-All buttons appear and act on
// every target, the same way multiple Arduinos behave. Resolved as a number
// so the toolbar only re-renders when the count changes. The predicate is a
// cheap string test (no JSON.parse) since this selector runs on every store
// change, including high-frequency simulation churn. (The compile/run paths
// deliberately act on ALL custom chips, not just programmable ones.)
const targetCount = useSimulatorStore((s) => {
let chips = 0;
for (const c of s.components) {
if (c.metadataId !== 'custom-chip') continue;
const p = c.properties as Record<string, unknown>;
if (String(p?.programFile ?? '').trim() || String(p?.chipJson ?? '').includes('"programTargets"'))
chips++;
}
return s.boards.length + chips;
});
// Circuit-verification modal state. When `pendingRun` is non-null we've
// already paid the cost of solving + analysing — the user can either
// bail out or proceed by running `pendingRun()`.
const [verification, setVerification] = useState<VerificationResult | null>(null);
const pendingRunRef = useRef<(() => void) | null>(null);
// Helper: report a Run event to the backend for analytics. Resolves the
// FQBN from the board kind so the backend can group by family/fqbn.
const reportRun = useCallback(
(boardKind: BoardKind | undefined) => {
const fqbn = boardKind ? BOARD_KIND_FQBN[boardKind] : null;
void reportRunEvent({
project_id: currentProject?.id ?? null,
board_fqbn: fqbn ?? null,
});
},
[currentProject],
);
const [compiling, setCompiling] = useState(false);
// True while the pre-flight circuit verification SPICE solve is running.
// Drives the Run-button spinner so the user gets feedback during the
// (sometimes multi-second, cold-worker) solve instead of a dead button.
const [verifying, setVerifying] = useState(false);
// Synchronous re-entrancy guard: a click while a run/verify is already in
// flight is ignored, so rapid clicks can't stack multiple verifications.
const runInFlightRef = useRef(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [libManagerOpen, setLibManagerOpen] = useState(false);
const [pendingLibraries, setPendingLibraries] = useState<string[]>([]);
const [installModalOpen, setInstallModalOpen] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);
const firmwareInputRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [missingLibHint, setMissingLibHint] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const moreMenuRef = useRef<HTMLDivElement>(null);
// Split-button menu for the multi-board Run control ("Run all" / "Run active only").
const [runMenuOpen, setRunMenuOpen] = useState(false);
const runMenuRef = useRef<HTMLDivElement>(null);
// Open the Library Manager when another component (e.g. the velxio.json entry
// in the FileExplorer) asks for it via a window event. Avoids prop-drilling
// the modal state down to the explorer.
useEffect(() => {
const open = () => setLibManagerOpen(true);
window.addEventListener('velxio-open-library-manager', open);
return () => window.removeEventListener('velxio-open-library-manager', open);
}, []);
// Surface a runtime circuit fault (e.g. an LED that burnt out from
// overcurrent during the live SPICE solve) in the output console, in red,
// under the "Circuit check" group — same place as the pre-flight findings.
// (Previously an inline toolbar toast that overlapped the Run/Stop buttons.)
// We do NOT auto-open the console here: the continuous solver can fault on
// load, and popping the console open then would be intrusive. The pre-flight
// (on Run) opens it; this entry then lands in the already-open log.
useEffect(() => {
const onFault = (e: Event) => {
const detail = (e as CustomEvent).detail as { message?: string } | undefined;
if (!detail?.message) return;
const text = detail.message;
setCompileLogs((prev) => [
...prev,
{ timestamp: new Date(), type: 'error', message: text, target: CIRCUIT_CHECK_TARGET },
]);
};
window.addEventListener('velxio-circuit-fault', onFault);
return () => window.removeEventListener('velxio-circuit-fault', onFault);
}, [setCompileLogs]);
useEffect(() => {
if (!moreMenuOpen) return;
const onClickOutside = (e: MouseEvent) => {
if (moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) {
setMoreMenuOpen(false);
}
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMoreMenuOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
document.addEventListener('keydown', onEsc);
return () => {
document.removeEventListener('mousedown', onClickOutside);
document.removeEventListener('keydown', onEsc);
};
}, [moreMenuOpen]);
// Close the Run split-menu on outside click / Escape (mirrors the more-menu).
useEffect(() => {
if (!runMenuOpen) return;
const onClickOutside = (e: MouseEvent) => {
if (runMenuRef.current && !runMenuRef.current.contains(e.target as Node)) {
setRunMenuOpen(false);
}
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setRunMenuOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
document.addEventListener('keydown', onEsc);
return () => {
document.removeEventListener('mousedown', onClickOutside);
document.removeEventListener('keydown', onEsc);
};
}, [runMenuOpen]);
// Compile All / Run All — runs sequentially, logs to console (no dialog)
const [compileAllRunning, setCompileAllRunning] = useState(false);
const addLog = useCallback(
(log: CompilationLog) => {
setCompileLogs((prev: CompilationLog[]) => [...prev, log]);
},
[setCompileLogs],
);
/**
* Make every custom-chip on the canvas runnable: compile its C source to
* WASM (when it has none yet) and, for programmable CPU chips, assemble or
* compile the program file it references into ROM bytes — stashing both on
* the chip component's `properties` so the next simulation start picks them
* up. Non-fatal by design: a chip that fails to compile is logged and
* skipped so the board itself still runs.
*/
const prepareCustomChips = useCallback(
async (
chips: { id: string; properties: Record<string, unknown> }[],
boardFiles: { name: string; content: string }[],
) => {
const codeChanged = useEditorStore.getState().codeChangedSinceLastCompile;
const updateComponent = useSimulatorStore.getState().updateComponent;
let failed = 0;
for (const chip of chips) {
// Re-read the freshest properties each iteration (an earlier chip's
// update doesn't touch this one, but be defensive).
const live = useSimulatorStore.getState().components.find((c) => c.id === chip.id);
const props = { ...(live?.properties ?? chip.properties) } as Record<string, unknown>;
const chipLabel = String(props.chipName ?? 'custom chip');
const sourceC = String(props.sourceC ?? '');
const chipJson = String(props.chipJson ?? '{}');
let changed = false;
// Stamp every line for this chip with its target so the console groups
// it under its own section (alongside the boards).
const chipTarget: CompileTarget = { id: chip.id, label: chipLabel, kind: 'chip' };
const clog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: chipTarget });
// 1. C -> WASM. Only when missing — the chip designer fills this too.
if (!String(props.wasmBase64 ?? '') && sourceC) {
clog('info', `Compiling chip "${chipLabel}" to WASM...`);
try {
const r = await compileChip(sourceC, chipJson);
if (r.success && r.wasm_base64) {
props.wasmBase64 = r.wasm_base64;
changed = true;
clog('success', `Chip "${chipLabel}" compiled (${r.byte_size} B WASM).`);
} else {
clog(
'error',
`Chip "${chipLabel}" WASM compile failed: ${r.error || r.stderr || 'unknown error'}`,
);
failed++;
}
} catch (e) {
clog(
'error',
`Chip "${chipLabel}" WASM compile error: ${e instanceof Error ? e.message : String(e)}`,
);
failed++;
}
}
// 2. program file -> ROM bytes (programmable CPU chips). Recompile
// when there's no ROM yet or the user edited code since last build.
const programFile = String(props.programFile ?? '').trim();
if (programFile && (!String(props.romBytes ?? '') || codeChanged)) {
// The program lives in the chip's OWN editor group (its collapsible
// section in the file explorer), separate from the board sketch.
// Fall back to the board files for older projects that still carried
// the program alongside sketch.ino in the board group.
const chipGroupFiles = useEditorStore
.getState()
.getGroupFiles(chipFileGroupId(chip.id));
const file =
chipGroupFiles.find((f) => f.name === programFile) ??
boardFiles.find((f) => f.name === programFile);
if (!file) {
clog('error', `Chip "${chipLabel}": program file "${programFile}" not found in the chip's files.`);
failed++;
} else {
const target = targetForChip(chipJson);
const fmt = formatForFile(programFile);
clog(
'info',
`Assembling "${programFile}" (target=${target}, format=${fmt}) for chip "${chipLabel}"...`,
);
try {
const rr = await compileRom(file.content, target, fmt);
if (rr.success && rr.rom_base64) {
props.romBytes = rr.rom_base64;
props.programFile = programFile;
changed = true;
clog('success', `ROM ready: ${rr.byte_size} B injected into "${chipLabel}".`);
} else {
clog(
'error',
`ROM compile failed for "${programFile}": ${rr.error || rr.stderr || 'unknown error'}`,
);
failed++;
}
} catch (e) {
clog(
'error',
`ROM compile error for "${programFile}": ${e instanceof Error ? e.message : String(e)}`,
);
failed++;
}
}
}
if (changed) {
updateComponent(chip.id, { properties: props } as any);
}
}
return { failed };
},
[addLog],
);
const handleCompile = async () => {
setCompiling(true);
setMessage(null);
setConsoleOpen(true);
// Wipe the previous build's output before we append anything new.
// Issue #209: lingering logs from prior compiles made it impossible
// to tell the latest errors / warnings apart from stale ones.
// Keep the "Circuit check" findings, though: a Run auto-compiles right
// after the pre-flight verification logs them, and clearing here would
// wipe a circuit warning the user just triggered.
setCompileLogs((prev) => prev.filter((l) => l.target?.id === CIRCUIT_CHECK_TARGET.id));
trackCompileCode();
// ── Custom-chip preparation ─────────────────────────────────────────
// Any custom-chip on the canvas is made "live" here so a single
// Compile / Run is enough — no separate trip through the chip designer
// or a manual ROM compile. For every custom-chip we:
// 1. compile its C source to WASM (when it has none yet), and
// 2. for programmable CPU chips, assemble/compile the program file it
// points at (larson.s, chaser.c, …) into ROM bytes.
// Both artefacts are stashed on the chip component's `properties`;
// CustomChipPart reads wasmBase64 + romBytes at simulation start.
//
// The chip program files are ALSO kept out of the Arduino sketch compile
// below (see `chipProgramFiles`) — otherwise arduino-cli/avr-gcc would
// try to build e.g. chaser.c and choke on SDCC-only syntax such as
// `__at(0xC000)`, which is exactly what broke the Z80 examples.
const componentsForCompile = useSimulatorStore.getState().components;
const customChips = componentsForCompile.filter((c) => c.metadataId === 'custom-chip');
const chipProgramFiles = new Set<string>();
for (const chip of customChips) {
const pf = String((chip.properties as any)?.programFile ?? '').trim();
if (pf) chipProgramFiles.add(pf);
}
if (customChips.length > 0) {
const boardFiles = activeBoard?.activeFileGroupId
? useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId)
: files;
await prepareCustomChips(customChips, boardFiles);
}
// ── End custom-chip preparation ─────────────────────────────────────
const kind = activeBoard?.boardKind;
// The active board's console target, defined up front so EVERY board path
// (Pi, MicroPython, arduino-cli, errors) groups its lines under one section.
const boardLabel = activeBoard ? boardDisplayName(activeBoard) : 'Unknown';
const boardTarget: CompileTarget | undefined = activeBoardId
? { id: activeBoardId, label: boardLabel, kind: 'board' }
: undefined;
const blog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: boardTarget });
// Raspberry Pi 3B doesn't need arduino-cli compilation
if (isPiBoardKind(kind)) {
blog('info', 'Raspberry Pi 3B: no compilation needed — run Python scripts directly.');
setMessage({ type: 'success', text: 'Ready (no compilation needed)' });
setCompiling(false);
return;
}
// MicroPython mode — no backend compilation needed
if (activeBoard?.languageMode === 'micropython' && activeBoardId) {
blog('info', 'MicroPython: loading firmware and user files...');
try {
const groupFiles = useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId);
const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content }));
await loadMicroPythonProgram(activeBoardId, pyFiles);
blog('success', 'MicroPython firmware loaded successfully');
setMessage({ type: 'success', text: 'MicroPython ready' });
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython';
blog('error', errMsg);
setMessage({ type: 'error', text: errMsg });
} finally {
setCompiling(false);
}
return;
}
const fqbn = kind ? BOARD_KIND_FQBN[kind] : null;
if (!fqbn) {
blog('error', `No FQBN for board kind: ${kind}`);
setMessage({ type: 'error', text: 'Unknown board' });
setCompiling(false);
return;
}
blog('info', `Starting compilation for ${boardLabel} (${fqbn})...`);
try {
const groupFiles = activeBoard?.activeFileGroupId
? useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId)
: files;
const sketchFiles = (groupFiles.length > 0 ? groupFiles : files)
// Keep chip-program files (a chip's programFile, or .s/.asm/.hex/.bin)
// out of the arduino-cli build — they're compiled to ROM above, not
// Arduino sources, and avr-gcc chokes on e.g. SDCC's __at().
.filter((f) => !chipProgramFiles.has(f.name) && !isChipProgramFile(f.name))
.map((f) => ({
name: f.name,
content: f.content,
}));
// Stream live cmake + ninja output into the compilation console as
// it arrives, instead of waiting for the whole build to finish.
// Each poll the backend returns the cumulative stdout buffer; we
// append only the delta since the previous call as 'info' lines.
let lastStreamedLen = 0;
const result = await compileCode(
sketchFiles,
fqbn,
currentProject?.id ?? null,
({ stdout }) => {
if (stdout.length <= lastStreamedLen) return;
const delta = stdout.slice(lastStreamedLen);
lastStreamedLen = stdout.length;
const newLines = delta.split('\n').filter((s) => s.trim());
if (!newLines.length) return;
const now = new Date();
setCompileLogs((prev: CompilationLog[]) => [
...prev,
...newLines.map((line) => ({
timestamp: now,
type: 'info' as const,
message: line,
target: boardTarget,
})),
]);
},
// Per-board ESP32 build options + SPIFFS uploads. Undefined for AVR
// / RP2040 boards (ignored on those paths by the backend).
{
boardOptions: activeBoard?.boardOptions,
spiffsFiles: activeBoard?.spiffsFiles,
// P2.4 — THIS board's declared manifest (compile scope). Per-board so
// two boards can use different libraries without clashing.
libraries: activeBoard?.libraries?.length ? activeBoard.libraries : null,
},
);
// After the build settles, append the structured analysis on top of
// the live stream — parseCompileResult highlights FAILED blocks and
// tags compiler errors with type='error', which the console uses for
// colour + the auto-switch-to-errors filter.
const resultLogs = parseCompileResult(result, boardLabel, boardTarget);
setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]);
if (result.success) {
const program = result.hex_content ?? result.binary_content ?? null;
if (program && activeBoardId) {
compileBoardProgram(activeBoardId, program);
if (result.has_wifi !== undefined) {
updateBoard(activeBoardId, { hasWifi: result.has_wifi });
}
}
setMessage({ type: 'success', text: 'Compiled successfully' });
markCompiled();
setMissingLibHint(false);
} else {
const errText = result.error || result.stderr || 'Compile failed';
setMessage({ type: 'error', text: errText });
// Issue #208: drop the previous successful program from this
// board so a subsequent Run cannot silently execute stale code
// that doesn't match the editor any more. The Run button gates
// on `!compiledProgram` and will refuse + force a re-compile.
if (activeBoardId) {
updateBoard(activeBoardId, { compiledProgram: null });
}
// Detect missing library errors — common patterns:
// "No such file or directory" for #include, "fatal error: XXX.h"
const looksLikeMissingLib =
/No such file or directory|fatal error:.*\.h|library not found/i.test(errText);
setMissingLibHint(looksLikeMissingLib);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Compile failed';
blog('error', errMsg);
setMessage({ type: 'error', text: errMsg });
} finally {
setCompiling(false);
}
};
// Track whether we should auto-run after compilation completes
const autoRunAfterCompile = useRef(false);
/**
* Pre-flight safety check: solves the current circuit and flags shorts,
* LED over-current and resistor over-power. Returns the result. When the
* solver fails to converge (degenerate netlist, no power source, …) we
* silently report a clean result so the user isn't blocked on circuits
* that aren't physically meaningful yet.
*/
const runVerification = useCallback(async (): Promise<VerificationResult | null> => {
try {
const sim = useSimulatorStore.getState();
// Skip if the circuit hasn't got anything analysable on it yet.
const hasSource = sim.components.some(
(c) => c.metadataId.startsWith('signal-generator') || c.metadataId.startsWith('battery'),
);
if (!hasSource && sim.boards.length === 0) return null;
const snap = {
components: sim.components.map((c) => ({
id: c.id,
metadataId: c.metadataId,
properties: c.properties,
})),
wires: sim.wires,
boards: sim.boards.map((b) => {
// Realistic pre-flight: simulate the WORST CASE — every digital
// pin connected to a load is forced HIGH at the board's vcc.
// This is what we want because the user's sketch WILL eventually
// do `digitalWrite(pin, HIGH)` (otherwise why is the LED wired?).
// Testing idle state would never flag a missing series resistor
// because the LED draws zero current when its pin is LOW.
//
// Caveat: pins wired only to inputs (e.g. a pull-up resistor +
// button) get over-driven here too. The verifier rules are
// already tolerant — a properly-spec'd pull-up sees minimal
// current and doesn't trip overcurrent / overpower. A circuit
// that would actually fault under HIGH is flagged correctly.
const pinStates: Record<string, PinSourceState> = {};
const group = BOARD_PIN_GROUPS[b.boardKind] ?? BOARD_PIN_GROUPS.default;
const wiredPinNames = new Set<string>();
for (const w of sim.wires) {
if (w.start.componentId === b.id) wiredPinNames.add(w.start.pinName);
if (w.end.componentId === b.id) wiredPinNames.add(w.end.pinName);
}
for (const pinName of wiredPinNames) {
// Skip GND / power-rail pin names — they belong to the rail
// groups and don't need to be re-asserted as digital sources.
if (group.gnd.includes(pinName)) continue;
if (group.vcc_pins.includes(pinName)) continue;
const arduinoPin = Number.parseInt(pinName, 10);
// Skip pins we can't identify as a digital GPIO (e.g.
// 'AREF', 'RESET', 'TX', 'RX' on some boards). Those are
// either rail-ish or non-driven by the sketch.
if (Number.isNaN(arduinoPin)) continue;
pinStates[pinName] = { type: 'digital', v: group.vcc };
}
return { id: b.id, boardKind: b.boardKind, pinStates };
}),
};
const input = buildInputFromStore(snap);
const result = await verifyCircuit(input);
// Concise outcome log — verification failing silently in production is
// hard to spot otherwise (the rules read 0 A when currents are missing).
console.log(
'[verify]',
JSON.stringify({
errors: result.errors.map((e) => e.code),
warnings: result.warnings.map((w) => w.code),
solved: !!result.solve,
branches: result.solve ? Object.keys(result.solve.branchCurrents) : null,
nodes: result.solve ? Object.keys(result.solve.nodeVoltages) : null,
}),
);
return result;
} catch (err) {
console.warn('[verifyCircuit] failed', err);
return null;
}
}, []);
/**
* Returns true if the caller should proceed inline. All findings are written
* to the output console (red errors / orange warnings, "Circuit check"
* group). If the verifier finds errors we also stash a resume callback in
* `pendingRunRef` and pop the verification modal; the resume callback
* re-enters `handleRun` with `skipVerify = true` so we don't loop.
* Warnings-only results don't block — the console entry is enough.
*/
const checkOrBlock = useCallback(
async (resume: () => void): Promise<boolean> => {
const result = await runVerification();
if (!result) return true;
if (result.errors.length === 0 && result.warnings.length === 0) return true;
// Write every finding to the output console under "Circuit check" — red
// for errors, orange for warnings — so there's one persistent, unified
// diagnostics log next to the compiler output (Proteus-style). Replace
// any prior circuit-check entries so repeated runs stay clean, and open
// the console so the findings are visible.
const now = new Date();
setCompileLogs((prev) => [
...prev.filter((l) => l.target?.id !== CIRCUIT_CHECK_TARGET.id),
...result.errors.map((e) => ({
timestamp: now,
type: 'error' as const,
message: e.message,
target: CIRCUIT_CHECK_TARGET,
})),
...result.warnings.map((w) => ({
timestamp: now,
type: 'warning' as const,
message: w.message,
target: CIRCUIT_CHECK_TARGET,
})),
]);
setConsoleOpen(true);
// Warnings only — non-blocking; the console entry is enough, run continues.
if (result.errors.length === 0) return true;
// Errors → also pop the modal so the user makes an explicit Run-anyway /
// Cancel decision; the console keeps the persistent red record.
pendingRunRef.current = resume;
setVerification(result);
return false;
},
[runVerification, setCompileLogs, setConsoleOpen],
);
const handleRun = async (skipVerify = false) => {
console.log('[handleRun] click', { activeBoardId, running, codeChangedSinceLastCompile });
// Pre-flight: solve the circuit and check for shorts / overcurrent /
// overpower. If anything trips we hand control to the modal, which
// resumes by calling `handleRun(true)` for "Run anyway".
if (!skipVerify) {
// The verification solve can take a second or two (cold ngspice worker).
// Show the Run-button spinner and ignore re-clicks while it runs — the
// button otherwise looks idle and gets clicked repeatedly, stacking
// multiple verifications.
if (runInFlightRef.current) return;
runInFlightRef.current = true;
setVerifying(true);
let ok = false;
try {
ok = await checkOrBlock(() => handleRun(true));
} finally {
setVerifying(false);
runInFlightRef.current = false;
}
if (!ok) return;
}
// Board-less circuits have no MCU to start. If there are custom-chip CPUs
// on the canvas, compile them (WASM + ROM) and re-attach so they pick up
// the fresh WASM — Velxio runs custom chips with no Arduino/ESP32 board,
// as a general-purpose electronics simulator. Then resume the electrical
// solver (replays any switch toggles captured while paused).
if (isBoardless) {
const customChips = useSimulatorStore
.getState()
.components.filter((c) => c.metadataId === 'custom-chip');
if (customChips.length > 0) {
setCompiling(true);
setConsoleOpen(true);
// Fresh chip output, but keep the circuit pre-flight findings just
// logged by checkOrBlock so they survive a "Run anyway".
setCompileLogs((prev) => prev.filter((l) => l.target?.id === CIRCUIT_CHECK_TARGET.id));
try {
await prepareCustomChips(customChips, files);
} catch (e) {
addLog({
timestamp: new Date(),
type: 'error',
message: e instanceof Error ? e.message : String(e),
});
}
setCompiling(false);
// Force the chip parts to re-attach with their freshly compiled WASM.
useSimulatorStore.getState().restartParts();
}
setElectricalPaused(false);
setMessage(null);
return;
}
if (activeBoardId) {
const board = boards.find((b) => b.id === activeBoardId);
console.log('[handleRun] active board', {
id: board?.id,
kind: board?.boardKind,
hasCompiledProgram: !!board?.compiledProgram,
compiledProgramLen: board?.compiledProgram?.length ?? 0,
});
// MicroPython mode: stop any running session first, then reload firmware + start
if (board?.languageMode === 'micropython') {
trackRunSimulation(board.boardKind);
reportRun(board.boardKind);
// Always stop the current session so the new run gets a clean QEMU boot.
// This also prevents the double start_esp32 that occurs when the bridge
// is already connected and startBoard() is called again.
if (board.running) {
stopBoard(activeBoardId);
// Give the WebSocket a moment to close cleanly before reconnecting.
await new Promise((resolve) => setTimeout(resolve, 300));
}
setCompiling(true);
setMessage(null);
const mpyTarget: CompileTarget = {
id: activeBoardId,
label: boardDisplayName(board),
kind: 'board',
};
const mlog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: mpyTarget });
mlog('info', 'MicroPython: loading firmware and user files...');
try {
const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId);
const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content }));
await loadMicroPythonProgram(activeBoardId, pyFiles);
mlog('success', 'MicroPython firmware loaded');
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython';
mlog('error', errMsg);
setMessage({ type: 'error', text: errMsg });
setCompiling(false);
return;
}
setCompiling(false);
startBoard(activeBoardId);
setMessage(null);
return;
}
const isQemuBoard = isQemuBoardKind(board?.boardKind);
// QEMU boards: auto-compile if no firmware available yet
if (isQemuBoard) {
console.log('[handleRun] QEMU path');
if (!board?.compiledProgram || codeChangedSinceLastCompile) {
console.log('[handleRun] auto-compile + run');
autoRunAfterCompile.current = true;
await handleCompile();
const updatedBoard = useSimulatorStore
.getState()
.boards.find((b) => b.id === activeBoardId);
console.log('[handleRun] after compile', {
hasCompiledProgram: !!updatedBoard?.compiledProgram,
compiledProgramLen: updatedBoard?.compiledProgram?.length ?? 0,
autoRunFlag: autoRunAfterCompile.current,
});
if (autoRunAfterCompile.current) {
autoRunAfterCompile.current = false;
if (updatedBoard?.compiledProgram) {
trackRunSimulation(updatedBoard.boardKind);
reportRun(updatedBoard.boardKind);
console.log('[handleRun] → startBoard', activeBoardId);
startBoard(activeBoardId);
setMessage(null);
} else {
// handleCompile returned without producing a firmware/program.
// Most common causes: arduino-cli unreachable, ESP-IDF compile
// error in the user's sketch, MicroPython firmware download
// failed, or the bridge rejected the load. handleCompile has
// already addLog'd the underlying error — surface a top-level
// toast too so the user knows their Run click didn't silently
// succeed.
const isMicropython = updatedBoard?.languageMode === 'micropython';
const errText = isMicropython
? 'MicroPython firmware did not load. Click "Load MicroPython" to retry, or check the console for the underlying error.'
: 'Compilation produced no firmware. Check the output console for the underlying error.';
console.warn('[handleRun] compile finished but no compiledProgram — not starting');
setMessage({ type: 'error', text: errText });
addLog({ timestamp: new Date(), type: 'error', message: errText });
}
}
return;
}
trackRunSimulation(board?.boardKind);
reportRun(board?.boardKind);
console.log('[handleRun] → startBoard (already compiled)', activeBoardId);
startBoard(activeBoardId);
setMessage(null);
return;
}
// Auto-compile if no program or code changed since last compile
if (!board?.compiledProgram || codeChangedSinceLastCompile) {
autoRunAfterCompile.current = true;
await handleCompile();
// After compile, check if it succeeded and run
const updatedBoard = useSimulatorStore
.getState()
.boards.find((b) => b.id === activeBoardId);
if (autoRunAfterCompile.current && updatedBoard?.compiledProgram) {
autoRunAfterCompile.current = false;
trackRunSimulation(updatedBoard.boardKind);
reportRun(updatedBoard.boardKind);
startBoard(activeBoardId);
setMessage(null);
} else {
autoRunAfterCompile.current = false;
}
return;
}
trackRunSimulation(board?.boardKind);
reportRun(board?.boardKind);
startBoard(activeBoardId);
setMessage(null);
return;
}
// Legacy fallback
if (!compiledHex || codeChangedSinceLastCompile) {
autoRunAfterCompile.current = true;
await handleCompile();
const hex = useSimulatorStore.getState().compiledHex;
if (autoRunAfterCompile.current && hex) {
autoRunAfterCompile.current = false;
trackRunSimulation();
reportRun(undefined);
startSimulation();
setMessage(null);
} else {
autoRunAfterCompile.current = false;
}
} else {
trackRunSimulation();
reportRun(undefined);
startSimulation();
setMessage(null);
}
};
const handleStop = () => {
trackStopSimulation();
if (isBoardless) {
// Freeze the chip tick (the paused flag) AND clear the chip's output
// drives so its LEDs go dark on Stop — not frozen at their last frame.
setElectricalPaused(true);
clearAllChipDrives();
setMessage(null);
return;
}
// Stop EVERY running board — Run-All can start several, and leaving any
// running keeps chips ticking (their gate is boards.some(running)).
const runningBoards = useSimulatorStore.getState().boards.filter((b) => b.running);
if (runningBoards.length > 0) runningBoards.forEach((b) => stopBoard(b.id));
else if (activeBoardId) stopBoard(activeBoardId);
else stopSimulation();
// A chip wired to a board drives its LEDs via its own SPICE sources, which
// stopBoard doesn't touch — clear them so those LEDs also go dark.
clearAllChipDrives();
setMessage(null);
};
const handleReset = () => {
trackResetSimulation();
if (activeBoardId) resetBoard(activeBoardId);
else resetSimulation();
setMessage(null);
};
/**
* Compile every board on the canvas sequentially. Progress + per-board
* results stream to the existing compilation console — no separate dialog.
* Returns the count of boards that ended up with a runnable program (so
* Run All can use it to decide whether to proceed to start them).
*/
const compileAllBoards = async (): Promise<{ ok: number; failed: number }> => {
const boardsList = useSimulatorStore.getState().boards;
// Every custom-chip is a target too — Compile-All / Run-All build chips
// (WASM + ROM) alongside boards, so the flow works for a board + chip, for
// several chips with no board, etc.
const allCustomChips = useSimulatorStore
.getState()
.components.filter((c) => c.metadataId === 'custom-chip');
if (boardsList.length === 0 && allCustomChips.length === 0) return { ok: 0, failed: 0 };
setCompileAllRunning(true);
setConsoleOpen(true);
const targetSummary = [
boardsList.length ? `${boardsList.length} board${boardsList.length === 1 ? '' : 's'}` : '',
allCustomChips.length ? `${allCustomChips.length} chip${allCustomChips.length === 1 ? '' : 's'}` : '',
]
.filter(Boolean)
.join(' + ');
addLog({
timestamp: new Date(),
type: 'info',
message: `Compiling all targets (${targetSummary})...`,
});
// Make every custom-chip live (WASM + ROM) before compiling the boards,
// mirroring the single-board Compile path, and collect their program file
// names so they stay out of the arduino-cli builds below.
const chipProgramFiles = new Set<string>();
for (const chip of allCustomChips) {
const pf = String((chip.properties as any)?.programFile ?? '').trim();
if (pf) chipProgramFiles.add(pf);
}
let chipFailed = 0;
if (allCustomChips.length > 0) {
const everyFile = boardsList.flatMap((b) =>
useEditorStore.getState().getGroupFiles(b.activeFileGroupId),
);
chipFailed = (await prepareCustomChips(allCustomChips, everyFile)).failed;
}
let ok = 0;
let boardFailed = 0;
for (const board of boardsList) {
const label = boardDisplayName(board);
// Stamp this board's lines so the console groups them under its section.
const boardTarget: CompileTarget = { id: board.id, label, kind: 'board' };
const blog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: boardTarget });
if (isPiBoardKind(board.boardKind)) {
blog('info', 'skipped (no compilation needed)');
ok++;
continue;
}
const fqbn = BOARD_KIND_FQBN[board.boardKind];
if (!fqbn) {
blog('error', 'no FQBN configured');
boardFailed++;
continue;
}
blog('info', 'compiling...');
try {
const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId);
const sketchFiles = groupFiles
.filter((f) => !chipProgramFiles.has(f.name) && !isChipProgramFile(f.name))
.map((f) => ({ name: f.name, content: f.content }));
// Stream live cmake + ninja output per-board (Compile-All flow).
let lastStreamedLen = 0;
const result = await compileCode(
sketchFiles,
fqbn,
currentProject?.id ?? null,
({ stdout }) => {
if (stdout.length <= lastStreamedLen) return;
const delta = stdout.slice(lastStreamedLen);
lastStreamedLen = stdout.length;
const newLines = delta.split('\n').filter((s) => s.trim());
if (!newLines.length) return;
const now = new Date();
setCompileLogs((prev: CompilationLog[]) => [
...prev,
// No `${label}: ` prefix — the target section header carries it.
...newLines.map((line) => ({
timestamp: now,
type: 'info' as const,
message: line,
target: boardTarget,
})),
]);
},
{ boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null },
);
const resultLogs = parseCompileResult(result, label, boardTarget);
setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]);
if (result.success) {
const program = result.hex_content ?? result.binary_content ?? null;
if (program) {
compileBoardProgram(board.id, program);
if (result.has_wifi !== undefined) {
updateBoard(board.id, { hasWifi: result.has_wifi });
}
}
ok++;
} else {
boardFailed++;
}
} catch (err) {
blog('error', err instanceof Error ? err.message : String(err));
boardFailed++;
}
}
const failed = boardFailed + chipFailed;
const chipOk = allCustomChips.length - chipFailed;
const doneParts = [];
if (boardsList.length)
doneParts.push(`${ok} board${ok === 1 ? '' : 's'} ok${boardFailed > 0 ? `, ${boardFailed} failed` : ''}`);
if (allCustomChips.length)
doneParts.push(`${chipOk} chip${chipOk === 1 ? '' : 's'} ok${chipFailed > 0 ? `, ${chipFailed} failed` : ''}`);
addLog({
timestamp: new Date(),
type: failed > 0 ? 'error' : 'success',
message: `Done — ${doneParts.join('; ')}`,
});
if (failed === 0) markCompiled();
setCompileAllRunning(false);
return { ok, failed };
};
const handleCompileAll = () => {
trackCompileCode();
void compileAllBoards();
};
/**
* Run All = compile every target (boards + chips) if needed, then start every
* one: boards via startBoard, chips via restartParts (re-attach with the
* fresh WASM/ROM) + resuming the electrical solver when there's no board.
* Mirrors single Run, generalised across all targets.
*/
const handleRunAll = async (skipVerify = false) => {
const sim = useSimulatorStore.getState();
const boardsList = sim.boards;
const chips = sim.components.filter((c) => c.metadataId === 'custom-chip');
if (boardsList.length === 0 && chips.length === 0) return;
// Same pre-flight safety check as handleRun — block on shorts / overcurrent
// before starting every board, with a "Run anyway" escape.
if (!skipVerify) {
const ok = await checkOrBlock(() => handleRunAll(true));
if (!ok) return;
}
// A chip needs compiling when it has no WASM yet, or it references a program
// file but hasn't been assembled to ROM.
const chipNeedsCompile = chips.some((c) => {
const p = c.properties as Record<string, unknown>;
const programFile = String(p?.programFile ?? '').trim();
return !String(p?.wasmBase64 ?? '') || (programFile && !String(p?.romBytes ?? ''));
});
const needsCompile =
codeChangedSinceLastCompile ||
chipNeedsCompile ||
boardsList.some(
(b) =>
!isPiBoardKind(b.boardKind) &&
b.languageMode !== 'micropython' &&
!b.compiledProgram,
);
if (needsCompile) {
const { failed } = await compileAllBoards();
if (failed > 0) return; // a board failed — don't start anything
}
// Start every board (compiledProgram may have changed during compile).
const refreshed = useSimulatorStore.getState().boards;
for (const board of refreshed) {
if (board.running) continue;
if (isQemuBoardKind(board.boardKind) || board.compiledProgram || board.languageMode === 'micropython') {
trackRunSimulation(board.boardKind);
reportRun(board.boardKind);
startBoard(board.id);
}
}
// Run the chips: re-attach so they pick up the freshly compiled WASM/ROM.
// The chip tick gates on a running board, so when NO board actually started
// (board-less, or a board that compiled to nothing) resume the electrical
// solver instead, otherwise the chips would stay frozen.
if (chips.length > 0) {
useSimulatorStore.getState().restartParts();
const anyBoardRunning = useSimulatorStore.getState().boards.some((b) => b.running);
if (!anyBoardRunning) setElectricalPaused(false);
}
};
const handleExport = async () => {
try {
const {
components,
wires,
boardPosition,
boardType: legacyBoardType,
} = useSimulatorStore.getState();
const projectName =
files.find((f) => f.name.endsWith('.ino'))?.name.replace('.ino', '') || 'velxio-project';
await exportToWokwiZip(files, components, wires, legacyBoardType, projectName, boardPosition);
} catch (err) {
setMessage({ type: 'error', text: 'Export failed.' });
}
};
// Phase 3 D3.2 — Schematic screenshot. Pro-tier-gated by the backend.
// Same UX pattern as BOM export: everyone can click; 402 redirects to
// /pricing. The server-side headless chromium renders the canvas and
// returns a PNG, which we trigger a download for.
const handleExportScreenshot = async () => {
const projectId = currentProject?.id;
if (!projectId) {
setMessage({ type: 'error', text: 'Save the project before exporting an image.' });
return;
}
setMessage({ type: 'info', text: 'Rendering screenshot — may take 5-10 seconds…' });
try {
const resp = await fetch(`/api/pro/projects/${projectId}/screenshot.png`, {
credentials: 'include',
});
if (resp.status === 402) {
// Fire the in-place upgrade modal instead of bouncing to /pricing —
// keeps the user in the editor with full context. The pro overlay's
// UpgradeGate listens for this event and opens UpgradePromptModal.
window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', {
detail: { componentName: 'Schematic screenshot export' },
}));
return;
}
if (resp.status === 401) {
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
return;
}
if (resp.status === 422) {
setMessage({ type: 'error', text: 'Add at least one component to export an image.' });
return;
}
if (!resp.ok) {
setMessage({ type: 'error', text: 'Screenshot export failed.' });
return;
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const cd = resp.headers.get('Content-Disposition') || '';
const m = /filename="?([^"]+)"?/.exec(cd);
a.download = m ? m[1] : `velxio-${projectId}.png`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setMessage({ type: 'success', text: 'Screenshot downloaded.' });
} catch {
setMessage({ type: 'error', text: 'Screenshot export failed.' });
}
};
// Phase 3 D3.1 — BOM export. Pro-tier-gated by the backend (402 if not pro).
// We let everyone click; the 402 response feeds the upgrade prompt below
// so free/maker users hit the funnel naturally instead of an obviously-
// locked button (which they'd just dismiss).
const handleExportBom = async () => {
const projectId = currentProject?.id;
if (!projectId) {
setMessage({ type: 'error', text: 'Save the project before exporting a BOM.' });
return;
}
try {
const resp = await fetch(`/api/pro/projects/${projectId}/bom.csv`, {
credentials: 'include',
});
if (resp.status === 402) {
// Fire the in-place upgrade modal instead of bouncing to /pricing —
// keeps the user in the editor with full context. The pro overlay's
// UpgradeGate listens for this event and opens UpgradePromptModal.
window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', {
detail: { componentName: 'BOM export' },
}));
return;
}
if (resp.status === 401) {
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
return;
}
if (!resp.ok) {
setMessage({ type: 'error', text: 'BOM export failed.' });
return;
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Filename comes from Content-Disposition; pick a fallback.
const cd = resp.headers.get('Content-Disposition') || '';
const m = /filename="?([^"]+)"?/.exec(cd);
a.download = m ? m[1] : `bom-${projectId}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
setMessage({ type: 'error', text: 'BOM export failed.' });
}
};
const handleFirmwareUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (firmwareInputRef.current) firmwareInputRef.current.value = '';
if (!file) return;
setConsoleOpen(true);
addLog({ timestamp: new Date(), type: 'info', message: `Loading firmware: ${file.name}...` });
try {
const boardKind = activeBoard?.boardKind;
if (!boardKind) {
setMessage({ type: 'error', text: 'No board selected' });
return;
}
const result = await readFirmwareFile(file, boardKind);
// Architecture mismatch warning for ELF files
if (result.elfInfo?.suggestedBoard && result.elfInfo.suggestedBoard !== boardKind) {
const detected = result.elfInfo.architectureName;
const current = activeBoard ? boardDisplayName(activeBoard) : boardKind;
addLog({
timestamp: new Date(),
type: 'info',
message: `Note: Detected ${detected} architecture, but current board is ${current}. Loading anyway.`,
});
}
if (activeBoardId) {
compileBoardProgram(activeBoardId, result.program);
markCompiled();
addLog({ timestamp: new Date(), type: 'info', message: result.message });
setMessage({ type: 'success', text: `Firmware loaded: ${file.name}` });
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load firmware';
addLog({ timestamp: new Date(), type: 'error', message: errMsg });
setMessage({ type: 'error', text: errMsg });
}
};
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!importInputRef.current) return;
importInputRef.current.value = '';
if (!file) return;
try {
const result = await importProjectFile(file);
if (result.kind === 'vlx') {
// importVlxFile already wrote into the stores.
setMessage({ type: 'success', text: `Imported ${file.name}` });
return;
}
// .zip path: apply the parsed payload to the stores ourselves, then
// surface any missing libraries via the existing install modal.
const { loadFiles } = useEditorStore.getState();
const { setComponents, setWires, setBoardType, setBoardPosition, stopSimulation } =
useSimulatorStore.getState();
stopSimulation();
if (result.boardType) setBoardType(result.boardType);
setBoardPosition(result.boardPosition);
setComponents(result.components);
setWires(result.wires);
if (result.files.length > 0) loadFiles(result.files);
setMessage({ type: 'success', text: `Imported ${file.name}` });
if (result.libraries.length > 0) {
setPendingLibraries(result.libraries);
setInstallModalOpen(true);
}
} catch (err: any) {
setMessage({ type: 'error', text: err?.message || 'Import failed.' });
}
};
return (
<>
<div className="editor-toolbar-wrapper" style={{ position: 'relative' }}>
<div className="editor-toolbar" ref={toolbarRef}>
{/* MicroPython language selector — only when active board supports it.
The board context pill that used to live here was removed: it
duplicated the BoardSelector dropdown elsewhere in the toolbar. */}
{activeBoard && BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) && (
<select
className="tb-lang-select"
value={activeBoard.languageMode ?? 'arduino'}
onChange={(e) => {
if (activeBoardId)
setBoardLanguageMode(activeBoardId, e.target.value as LanguageMode);
}}
title={t('editor.toolbar.languageMode')}
style={{
background: '#2d2d2d',
color: '#ccc',
border: '1px solid #444',
borderRadius: 4,
padding: '2px 4px',
fontSize: 11,
cursor: 'pointer',
outline: 'none',
marginRight: 4,
}}
>
<option value="arduino">Arduino C++</option>
<option value="micropython">MicroPython</option>
</select>
)}
<div className="toolbar-group">
{/* Compile */}
<button
onClick={handleCompile}
disabled={compiling || !activeBoard}
className="tb-btn tb-btn-compile"
title={
!activeBoard
? t('editor.toolbar.compile.addBoard')
: compiling
? t('editor.toolbar.compile.loading')
: activeBoard?.languageMode === 'micropython'
? t('editor.toolbar.compile.loadMicropython')
: t('editor.toolbar.compile.compile')
}
>
{compiling ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="spin"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
)}
</button>
<div className="tb-divider" />
{/* Run — in a multi-board project this runs ALL boards (the wired
boards are one system; running a subset is almost never
intended), with a split-menu to still run only the active board.
Single-board / board-less behaviour is unchanged. */}
<div className="tb-run-split" ref={runMenuRef}>
<button
onClick={() => (isMultiBoard ? handleRunAll() : handleRun())}
disabled={
isBoardless
? digitalRunning || verifying
: isMultiBoard
? compileAllRunning || anyBoardRunning || verifying
: running || compiling || verifying || !activeBoard
}
className="tb-btn tb-btn-run"
title={
verifying
? t('editor.toolbar.run.verifying', 'Checking circuit...')
: isBoardless
? digitalRunning
? 'Digital simulation running'
: 'Resume digital simulation'
: isMultiBoard
? t('editor.toolbar.runAll')
: !activeBoard
? t('editor.toolbar.run.addBoard')
: activeBoard?.languageMode === 'micropython'
? t('editor.toolbar.run.runMicropython')
: t('editor.toolbar.run.run')
}
>
{verifying || compiling ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="spin"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="5,3 19,12 5,21" />
</svg>
)}
</button>
{isMultiBoard && (
<button
className="tb-btn tb-btn-run-caret"
onClick={() => setRunMenuOpen((o) => !o)}
disabled={compileAllRunning || anyBoardRunning || verifying}
title={t('editor.toolbar.run.options', 'Run options')}
aria-haspopup="true"
aria-expanded={runMenuOpen}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)}
{isMultiBoard && runMenuOpen && (
<div className="tb-run-menu" role="menu">
<button
role="menuitem"
className="tb-run-menu-item"
onClick={() => {
setRunMenuOpen(false);
handleRunAll();
}}
>
{t('editor.toolbar.runAll')}
</button>
<button
role="menuitem"
className="tb-run-menu-item"
disabled={!activeBoard}
onClick={() => {
setRunMenuOpen(false);
handleRun();
}}
>
{t('editor.toolbar.run.runActiveOnly', {
name: activeBoard ? boardDisplayName(activeBoard) : '',
defaultValue: `Run only ${activeBoard ? boardDisplayName(activeBoard) : ''}`,
})}
</button>
</div>
)}
</div>
{/* Stop */}
<button
onClick={handleStop}
disabled={isBoardless ? !digitalRunning : !anyBoardRunning}
className="tb-btn tb-btn-stop"
title={isBoardless ? 'Freeze digital simulation' : t('editor.toolbar.stop')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<rect x="3" y="3" width="18" height="18" rx="2" />
</svg>
</button>
{/* Reset */}
<button
onClick={handleReset}
disabled={!compiledHex && !activeBoard?.compiledProgram}
className="tb-btn tb-btn-reset"
title={t('editor.toolbar.reset')}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</button>
{targetCount > 1 && (
<>
<div className="tb-divider" />
{/* Compile All — boards + programmable chips */}
<button
onClick={handleCompileAll}
disabled={compileAllRunning}
className="tb-btn tb-btn-compile-all"
title={t('editor.toolbar.compileAll')}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
<path d="M6 20h4M14 4l4 4" strokeDasharray="2 2" />
</svg>
</button>
{/* Run All — only when the primary Run isn't already the
"run all boards" action (i.e. board + chip or chips-only
projects). For 2+ boards the split Run button covers it. */}
{!isMultiBoard && (
<button
onClick={() => handleRunAll()}
disabled={compileAllRunning || anyBoardRunning || digitalRunning}
className="tb-btn tb-btn-run-all"
title={t('editor.toolbar.runAll')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="3,3 11,12 3,21" />
<polygon points="13,3 21,12 13,21" />
</svg>
</button>
)}
</>
)}
</div>
{/* Center slot — a flexible spacer that keeps the right action group
pinned to the far right. Rendered unconditionally so the layout
holds even when no overlay supplies content here. */}
<div className="toolbar-center-slot">{centerSlot}</div>
<div className="toolbar-group toolbar-group-right">
{/* Hidden file input for project import. Accepts both .vlx
(Velxio native) and .zip (Wokwi bundle); the dispatcher in
utils/importProject.ts picks the right loader by extension. */}
<input
ref={importInputRef}
type="file"
accept={PROJECT_FILE_ACCEPT}
style={{ display: 'none' }}
onChange={handleImportFile}
/>
{/* Hidden file input for firmware upload */}
<input
ref={firmwareInputRef}
type="file"
accept=".hex,.bin,.elf,.ihex"
style={{ display: 'none' }}
onChange={handleFirmwareUpload}
/>
{/* Library Manager — always visible with label */}
<button
onClick={() => {
trackOpenLibraryManager();
setLibManagerOpen(true);
}}
className="tb-btn-libraries"
title={t('editor.toolbar.libraries.title')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<span className="tb-libraries-label">{t('editor.toolbar.libraries.label')}</span>
</button>
{/* Import zip — inline by default; container query at narrow
widths swaps this for the corresponding overflow-menu item. */}
<button
onClick={() => importInputRef.current?.click()}
className="tb-btn tb-btn-import-inline"
title={t('editor.toolbar.import')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
<button
onClick={() => handleExport()}
className="tb-btn tb-btn-export-inline"
title={t('editor.toolbar.export')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</button>
{/* Overflow "More" menu — collects the secondary actions
(BOM, Schematic image, Upload firmware) so the toolbar no
longer overflows on narrow widths. The two Pro items show
a small "PRO" pill in the menu so users know they're
premium BEFORE clicking, instead of being surprised by an
upgrade prompt. */}
<div className="tb-overflow-wrap" ref={moreMenuRef}>
<button
onClick={() => setMoreMenuOpen((v) => !v)}
className={`tb-btn tb-btn-overflow${moreMenuOpen ? ' tb-btn-overflow-active' : ''}`}
title={t('editor.toolbar.more', 'More')}
aria-haspopup="true"
aria-expanded={moreMenuOpen}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="1.8" />
<circle cx="12" cy="12" r="1.8" />
<circle cx="19" cy="12" r="1.8" />
</svg>
</button>
{moreMenuOpen && (
<div className="tb-overflow-menu" role="menu">
{/* Responsive items — hidden by default, shown via
container query when the toolbar is too narrow to
keep their inline twins. Keeps mobile users from
losing access to Import / Export entirely. */}
<button
className="tb-overflow-item tb-overflow-import"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
importInputRef.current?.click();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.importLabel', 'Import project')}</span>
</button>
<button
className="tb-overflow-item tb-overflow-export"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
handleExport();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.exportLabel', 'Export project (.zip)')}</span>
</button>
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
handleExportBom();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="16" rx="2" />
<line x1="3" y1="10" x2="21" y2="10" />
<line x1="9" y1="4" x2="9" y2="20" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.exportBomLabel', 'Bill of Materials (CSV)')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
handleExportScreenshot();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.exportScreenshotLabel', 'Schematic image (PNG)')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
firmwareInputRef.current?.click();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
<line x1="12" y1="15" x2="12" y2="22" />
<polyline points="8 18 12 22 16 18" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.uploadFirmwareLabel', 'Upload firmware')}</span>
</button>
{/* Sync to GitHub — Pro feature. Fires a window event the
pro overlay listens for; if no overlay is loaded (OSS
build) the click is a silent no-op which is fine —
OSS users can't have linked repos anyway. */}
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
window.dispatchEvent(new CustomEvent('velxio-pro-github-sync-prompt', {
detail: { projectId: currentProject?.id ?? null },
}));
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58 0-.29-.01-1.05-.02-2.06-3.34.72-4.04-1.61-4.04-1.61-.55-1.38-1.33-1.75-1.33-1.75-1.09-.74.08-.72.08-.72 1.2.08 1.84 1.24 1.84 1.24 1.07 1.84 2.81 1.31 3.5 1 .11-.78.42-1.31.76-1.62-2.66-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.11-3.18 0 0 1.01-.32 3.3 1.23A11.5 11.5 0 0 1 12 5.8c1.02.01 2.05.14 3.01.4 2.29-1.55 3.3-1.23 3.3-1.23.65 1.66.24 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.62-5.49 5.92.43.37.82 1.1.82 2.22 0 1.6-.02 2.89-.02 3.29 0 .32.22.7.83.58A12 12 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.githubSyncLabel', 'Sync to GitHub')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
{/* Share / Embed — free for all users with a public project.
Watermark removal on the embed is the Pro perk; the
Share modal itself is open to everyone so they can
copy the link / iframe snippet. */}
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
window.dispatchEvent(new CustomEvent('velxio-pro-share-prompt', {
detail: { projectId: currentProject?.id ?? null },
}));
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.shareLabel', 'Share / Embed')}</span>
</button>
{/* Record simulation — Pro feature. Dispatches a toggle the
pro overlay handles (plan check, board-type check,
start/stop the recorder). OSS build → no listener →
silent no-op. */}
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
window.dispatchEvent(new CustomEvent('velxio-pro-replay-record-toggle', {
detail: { projectId: currentProject?.id ?? null },
}));
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="7" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.recordLabel', 'Record simulation')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
</div>
)}
</div>
<div className="tb-divider" />
{/* Output Console toggle */}
<button
onClick={() => setConsoleOpen((v) => !v)}
className={`tb-btn tb-btn-output${consoleOpen ? ' tb-btn-output-active' : ''}`}
title={t('editor.toolbar.toggleConsole')}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
</button>
{rightSlot}
</div>
</div>
</div>
{/* Error detail bar */}
{message?.type === 'error' && message.text.length > 40 && !consoleOpen && (
<div className="toolbar-error-detail">{message.text}</div>
)}
{/* Missing library hint */}
{missingLibHint && (
<div className="tb-lib-hint">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>{t('editor.toolbar.libHint.message')}</span>
<button
className="tb-lib-hint-btn"
onClick={() => {
trackOpenLibraryManager();
setLibManagerOpen(true);
setMissingLibHint(false);
}}
>
{t('editor.toolbar.libHint.cta')}
</button>
<button
className="tb-lib-hint-close"
onClick={() => setMissingLibHint(false)}
title={t('editor.toolbar.libHint.dismiss')}
>
&times;
</button>
</div>
)}
<LibraryManagerModal isOpen={libManagerOpen} onClose={() => setLibManagerOpen(false)} />
<InstallLibrariesModal
isOpen={installModalOpen}
onClose={() => setInstallModalOpen(false)}
libraries={pendingLibraries}
/>
{verification && (
<CircuitVerificationModal
result={verification}
onCancel={() => {
pendingRunRef.current = null;
setVerification(null);
}}
onRunAnyway={() => {
const resume = pendingRunRef.current;
pendingRunRef.current = null;
setVerification(null);
resume?.();
}}
/>
)}
</>
);
};