import { useCallback, useMemo, useRef, useState } from 'react'; import type { BoardKind } from '../../types/board'; import { BOARD_KIND_LABELS } from '../../types/board'; import type { ESP32BoardOptions, ESP32CoreSelect, SpiffsFile, } from '../../types/boardOptions'; import { CORE_SELECT_OPTIONS, CPU_FREQ_OPTIONS, DEBUG_LEVEL_OPTIONS, FLASH_FREQ_OPTIONS, FLASH_MODE_OPTIONS, FLASH_SIZE_OPTIONS, PARTITION_SCHEME_FS_SIZE, PARTITION_SCHEME_LABELS, boardSupportsOpiPsram, boardSupportsPsram, getDefaultOptionsForKind, } from '../../types/boardOptions'; import './BoardOptionsModal.css'; interface BoardOptionsModalProps { isOpen: boolean; boardId: string; boardKind: BoardKind; currentOptions: ESP32BoardOptions | undefined; spiffsFiles: SpiffsFile[]; onClose: () => void; onApply: (next: ESP32BoardOptions) => void; onSpiffsChange: (next: SpiffsFile[]) => void; } type TabKey = 'options' | 'files'; const PARTITION_SCHEMES: (keyof typeof PARTITION_SCHEME_LABELS)[] = [ 'default', 'defaults_ffat', 'min_spiffs', 'min_ffat', 'no_ota', 'no_fs', 'huge_app', 'large_spiffs', 'rainmaker', ]; const MAX_FILE_BYTES = 1 * 1024 * 1024; // 1 MB per file warning const SOFT_TOTAL_CAP = 4 * 1024 * 1024; // 4 MB total guardrail function fmtBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(2)} MB`; } async function readFileAsBase64(file: File): Promise { const buf = await file.arrayBuffer(); const bytes = new Uint8Array(buf); let bin = ''; // Chunk to avoid the call-stack ceiling on large files. const CHUNK = 0x8000; for (let i = 0; i < bytes.length; i += CHUNK) { bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); } return btoa(bin); } export const BoardOptionsModal = ({ isOpen, boardId, boardKind, currentOptions, spiffsFiles, onClose, onApply, onSpiffsChange, }: BoardOptionsModalProps) => { const seed = useMemo( () => currentOptions ?? getDefaultOptionsForKind(boardKind), [currentOptions, boardKind], ); const [tab, setTab] = useState('options'); const [draft, setDraft] = useState(seed); const fileInputRef = useRef(null); if (!isOpen) return null; const showPsram = boardSupportsPsram(boardKind); const showOpi = boardSupportsOpiPsram(boardKind); const fsCapacity = PARTITION_SCHEME_FS_SIZE[draft.partitionScheme] ?? 0; const totalUploaded = spiffsFiles.reduce((sum, f) => sum + f.size, 0); const overSchemeCap = fsCapacity > 0 && totalUploaded > fsCapacity; const noFsButHasFiles = fsCapacity === 0 && spiffsFiles.length > 0; const nonDio = draft.flashMode !== 'dio'; const sameCore = draft.eventsRunOnCore === draft.arduinoRunsOnCore; const update = useCallback( (patch: Partial) => setDraft((d) => ({ ...d, ...patch })), [], ); const handleApply = () => { // Strip PSRAM for boards that don't support it (e.g. C3) so a stale // value can't smuggle through to the backend. const sanitised: ESP32BoardOptions = { ...draft }; if (!showPsram) sanitised.psram = 'disabled'; if (!showOpi && sanitised.psram === 'opi') sanitised.psram = 'enabled'; onApply(sanitised); onClose(); }; const handleFiles = async (files: FileList | null) => { if (!files || files.length === 0) return; const next: SpiffsFile[] = [...spiffsFiles]; for (const f of Array.from(files)) { if (next.some((existing) => existing.name === f.name)) { const overwrite = window.confirm( `A file named "${f.name}" already exists. Overwrite?`, ); if (!overwrite) continue; } const contentB64 = await readFileAsBase64(f); const entry: SpiffsFile = { name: f.name, contentB64, size: f.size, }; const i = next.findIndex((existing) => existing.name === f.name); if (i >= 0) next[i] = entry; else next.push(entry); } onSpiffsChange(next); }; const handleDelete = (name: string) => { onSpiffsChange(spiffsFiles.filter((f) => f.name !== name)); }; return (
e.stopPropagation()}>
Board Options
{BOARD_KIND_LABELS[boardKind]} - id {boardId}
{tab === 'options' ? ( <>

Memory

{draft.flashSize !== '4MB' && ( QEMU image grows )}

Speed

Flash

{nonDio && ( QEMU may fail to boot )}
{showPsram && (

PSRAM

)}

Debug

Concurrency

{sameCore && ( Both on same core )}

Tools

update({ eraseFlashOnUpload: e.target.checked })} /> Zero NVS / SPIFFS before next run
) : ( fileInputRef.current?.click()} onChange={handleFiles} onDelete={handleDelete} /> )}
); }; interface SpiffsPanelProps { files: SpiffsFile[]; fsCapacity: number; totalUploaded: number; overSchemeCap: boolean; noFsButHasFiles: boolean; fileInputRef: React.RefObject; onAdd: () => void; onChange: (files: FileList | null) => void; onDelete: (name: string) => void; } const SpiffsPanel = ({ files, fsCapacity, totalUploaded, overSchemeCap, noFsButHasFiles, fileInputRef, onAdd, onChange, onDelete, }: SpiffsPanelProps) => { const overSoft = totalUploaded > SOFT_TOTAL_CAP; return ( <> {noFsButHasFiles && (
Selected partition scheme has no filesystem. Uploaded files will be ignored at flash time. Switch to a scheme like default or min_spiffs to keep them.
)} {overSchemeCap && (
Total upload size ({fmtBytes(totalUploaded)}) exceeds the partition's SPIFFS capacity ({fmtBytes(fsCapacity)}). The image will not fit - remove files or pick a larger scheme.
)} {overSoft && !overSchemeCap && (
Total upload size exceeds the recommended 4 MB cap. Save / load operations may slow down.
)}
{fmtBytes(totalUploaded)}{' '} {fsCapacity > 0 ? `/ ${fmtBytes(fsCapacity)}` : '(no FS partition)'}
{ onChange(e.target.files); if (e.target) e.target.value = ''; }} /> {files.length === 0 ? (
No files. Click "Add file" to upload assets that will be flashed into the SPIFFS partition.
) : ( {files.map((f) => ( ))}
Name Size Actions
/{f.name} {f.size > MAX_FILE_BYTES && ( large )} {fmtBytes(f.size)}
)} ); };