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}
{/* Error detail bar */} {message?.type === 'error' && message.text.length > 40 && !consoleOpen && (
{message.text}
)} {/* Missing library hint */} {missingLibHint && (
{t('editor.toolbar.libHint.message')}
)} setLibManagerOpen(false)} /> setInstallModalOpen(false)} libraries={pendingLibraries} /> ); };