2012 lines
84 KiB
TypeScript
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')}
|
|
>
|
|
×
|
|
</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?.();
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|