import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '../../store/useEditorStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import type { BoardKind, LanguageMode } from '../../types/board';
import { BOARD_KIND_FQBN, BOARD_KIND_LABELS, BOARD_SUPPORTS_MICROPYTHON } from '../../types/board';
import { compileCode } from '../../services/compilation';
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 } from '../../utils/compilationLogger';
import { exportToWokwiZip, importFromWokwiZip } from '../../utils/wokwiZip';
import { readFirmwareFile } from '../../utils/firmwareLoader';
import {
trackCompileCode,
trackRunSimulation,
trackStopSimulation,
trackResetSimulation,
trackOpenLibraryManager,
} from '../../utils/analytics';
import './EditorToolbar.css';
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. The editor passes here so the tabs share the
* same row as the toolbar — keeping every action icon pinned and visible
* regardless of how narrow the editor pane gets.
*/
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 = {
'arduino-uno': '⬤',
'arduino-nano': '▪',
'arduino-mega': '▬',
'raspberry-pi-pico': '◆',
'raspberry-pi-3': '⬛',
esp32: '⬡',
'esp32-s3': '⬡',
'esp32-c3': '⬡',
};
const BOARD_PILL_COLOR: Record = {
'arduino-uno': '#4fc3f7',
'arduino-nano': '#4fc3f7',
'arduino-mega': '#4fc3f7',
'raspberry-pi-pico': '#ce93d8',
'raspberry-pi-3': '#ef9a9a',
esp32: '#a5d6a7',
'esp32-s3': '#a5d6a7',
'esp32-c3': '#a5d6a7',
};
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);
// 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);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [libManagerOpen, setLibManagerOpen] = useState(false);
const [pendingLibraries, setPendingLibraries] = useState([]);
const [installModalOpen, setInstallModalOpen] = useState(false);
const importInputRef = useRef(null);
const firmwareInputRef = useRef(null);
const toolbarRef = useRef(null);
const [missingLibHint, setMissingLibHint] = useState(false);
// 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],
);
const handleCompile = async () => {
setCompiling(true);
setMessage(null);
setConsoleOpen(true);
trackCompileCode();
const kind = activeBoard?.boardKind;
// Raspberry Pi 3B doesn't need arduino-cli compilation
if (kind === 'raspberry-pi-3') {
addLog({
timestamp: new Date(),
type: 'info',
message: '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) {
addLog({
timestamp: new Date(),
type: 'info',
message: '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);
addLog({
timestamp: new Date(),
type: 'success',
message: 'MicroPython firmware loaded successfully',
});
setMessage({ type: 'success', text: 'MicroPython ready' });
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython';
addLog({ timestamp: new Date(), type: 'error', message: errMsg });
setMessage({ type: 'error', text: errMsg });
} finally {
setCompiling(false);
}
return;
}
const fqbn = kind ? BOARD_KIND_FQBN[kind] : null;
const boardLabel = kind ? BOARD_KIND_LABELS[kind] : 'Unknown';
if (!fqbn) {
addLog({ timestamp: new Date(), type: 'error', message: `No FQBN for board kind: ${kind}` });
setMessage({ type: 'error', text: 'Unknown board' });
setCompiling(false);
return;
}
addLog({
timestamp: new Date(),
type: 'info',
message: `Starting compilation for ${boardLabel} (${fqbn})...`,
});
try {
const groupFiles = activeBoard?.activeFileGroupId
? useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId)
: files;
const sketchFiles = (groupFiles.length > 0 ? groupFiles : files).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,
})),
]);
},
);
// 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);
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 });
// 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';
addLog({ timestamp: new Date(), type: 'error', message: errMsg });
setMessage({ type: 'error', text: errMsg });
} finally {
setCompiling(false);
}
};
// Track whether we should auto-run after compilation completes
const autoRunAfterCompile = useRef(false);
const handleRun = async () => {
console.log('[handleRun] click', { activeBoardId, running, codeChangedSinceLastCompile });
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);
addLog({
timestamp: new Date(),
type: 'info',
message: '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);
addLog({
timestamp: new Date(),
type: 'success',
message: 'MicroPython firmware loaded',
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython';
addLog({ timestamp: new Date(), type: 'error', message: errMsg });
setMessage({ type: 'error', text: errMsg });
setCompiling(false);
return;
}
setCompiling(false);
startBoard(activeBoardId);
setMessage(null);
return;
}
const isQemuBoard =
board?.boardKind === 'raspberry-pi-3' ||
board?.boardKind === 'esp32' ||
board?.boardKind === 'esp32-s3' ||
board?.boardKind === 'esp32-cam' ||
board?.boardKind === 'esp32-c3' ||
board?.boardKind === 'esp32-devkit-c-v4' ||
board?.boardKind === 'wemos-lolin32-lite' ||
board?.boardKind === 'xiao-esp32-s3' ||
board?.boardKind === 'arduino-nano-esp32' ||
board?.boardKind === 'xiao-esp32-c3' ||
board?.boardKind === 'aitewinrobot-esp32c3-supermini';
// 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,
});
// For QEMU boards, always start even if compiledProgram is empty —
// the bridge can be told to start without firmware (for waiting on
// a later upload) and is the safest path when the binary may be
// present on the bridge but not yet reflected in the store.
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 {
console.warn('[handleRun] compile finished but no compiledProgram — not starting');
}
}
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 (activeBoardId) stopBoard(activeBoardId);
else stopSimulation();
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;
if (boardsList.length === 0) return { ok: 0, failed: 0 };
setCompileAllRunning(true);
setConsoleOpen(true);
addLog({
timestamp: new Date(),
type: 'info',
message: `Compiling all ${boardsList.length} board${boardsList.length === 1 ? '' : 's'}...`,
});
let ok = 0;
let failed = 0;
for (const board of boardsList) {
const label = BOARD_KIND_LABELS[board.boardKind] ?? board.boardKind;
if (board.boardKind === 'raspberry-pi-3') {
addLog({
timestamp: new Date(),
type: 'info',
message: `${label}: skipped (no compilation needed)`,
});
ok++;
continue;
}
const fqbn = BOARD_KIND_FQBN[board.boardKind];
if (!fqbn) {
addLog({
timestamp: new Date(),
type: 'error',
message: `${label}: no FQBN configured`,
});
failed++;
continue;
}
addLog({ timestamp: new Date(), type: 'info', message: `${label}: compiling...` });
try {
const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId);
const sketchFiles = groupFiles.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,
...newLines.map((line) => ({
timestamp: now,
type: 'info' as const,
message: `${label}: ${line}`,
})),
]);
},
);
const resultLogs = parseCompileResult(result, label);
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 {
failed++;
}
} catch (err) {
addLog({
timestamp: new Date(),
type: 'error',
message: `${label}: ${err instanceof Error ? err.message : String(err)}`,
});
failed++;
}
}
addLog({
timestamp: new Date(),
type: ok > 0 && failed === 0 ? 'success' : failed > 0 ? 'error' : 'info',
message: `Done — ${ok} succeeded, ${failed} failed`,
});
if (ok > 0 && failed === 0) markCompiled();
setCompileAllRunning(false);
return { ok, failed };
};
const handleCompileAll = () => {
trackCompileCode();
void compileAllBoards();
};
/** Run All = compile all (if needed) + start every board, mirroring single Run. */
const handleRunAll = async () => {
const boardsList = useSimulatorStore.getState().boards;
if (boardsList.length === 0) return;
// Compile if anything is missing a program or code changed since last compile
const needsCompile =
codeChangedSinceLastCompile ||
boardsList.some(
(b) =>
b.boardKind !== 'raspberry-pi-3' &&
b.languageMode !== 'micropython' &&
!b.compiledProgram,
);
if (needsCompile) {
const { failed } = await compileAllBoards();
if (failed > 0) return; // Don't start anything if any board failed
}
// Refresh list after compile (compiledProgram may have changed)
const refreshed = useSimulatorStore.getState().boards;
for (const board of refreshed) {
if (board.running) continue;
const isQemu =
board.boardKind === 'raspberry-pi-3' ||
board.boardKind === 'esp32' ||
board.boardKind === 'esp32-s3';
if (isQemu || board.compiledProgram || board.languageMode === 'micropython') {
trackRunSimulation(board.boardKind);
reportRun(board.boardKind);
startBoard(board.id);
}
}
};
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.' });
}
};
const handleFirmwareUpload = async (e: React.ChangeEvent) => {
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 ? BOARD_KIND_LABELS[activeBoard.boardKind] : 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) => {
const file = e.target.files?.[0];
if (!importInputRef.current) return;
importInputRef.current.value = '';
if (!file) return;
try {
const result = await importFromWokwiZip(file);
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 (
<>
{/* 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) && (
)}
{/* Compile */}
{/* Run */}
{/* Stop */}
{/* Reset */}
{boards.length > 1 && (
<>
{/* Compile All */}
{/* Run All */}
>
)}
{/* Center slot — file tabs share the row so action icons stay pinned. */}
{centerSlot &&
{centerSlot}
}
{/* Hidden file input for import (always present) */}
{/* Hidden file input for firmware upload */}
{/* Library Manager — always visible with label */}
{/* Import zip — was previously hidden in a 3-dot overflow menu;
inlined since there's space and the discoverability cost
outweighed the toolbar savings. */}
{/* Output Console toggle */}
{rightSlot}