import { create } from 'zustand'; import { generateUUID } from '../utils/uuid'; import { isPiBoardKind } from '../types/board'; export interface WorkspaceFile { id: string; name: string; content: string; modified: boolean; } const MAIN_ID = 'main-sketch'; const DEFAULT_INO_CONTENT = `// Arduino Blink Example void setup() { pinMode(LED_BUILTIN, OUTPUT); } void loop() { digitalWrite(LED_BUILTIN, HIGH); delay(1000); digitalWrite(LED_BUILTIN, LOW); delay(1000); }`; const DEFAULT_MICROPYTHON_CONTENT = `# MicroPython Blink for Raspberry Pi Pico from machine import Pin import time led = Pin(25, Pin.OUT) while True: led.toggle() time.sleep(1) `; // NOTE: avoid Pin.toggle() — it was only added to the ESP32 port in // MicroPython v1.21 (Oct 2023). The firmware Velxio ships is v1.20.0 // (April 2023), so Pin.toggle() raises AttributeError there. // See https://github.com/davidmonterocrespo24/velxio/issues/122 const DEFAULT_ESP32_MICROPYTHON_CONTENT = `# MicroPython Blink for ESP32 from machine import Pin import time led = Pin(2, Pin.OUT) # Built-in LED on GPIO 2 state = False while True: state = not state led.value(state) time.sleep(1) `; // Pure ESP-IDF mode (issue #139): the user's own app_main(), compiled by the // backend's ESP-IDF toolchain WITHOUT the arduino-esp32 component. GPIO 2 is // the built-in LED on most ESP32 dev boards. const DEFAULT_ESPIDF_CONTENT = `// ESP-IDF Blink Example #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "driver/gpio.h" #define LED_PIN GPIO_NUM_2 void app_main(void) { gpio_reset_pin(LED_PIN); gpio_set_direction(LED_PIN, GPIO_MODE_OUTPUT); while (1) { gpio_set_level(LED_PIN, 1); vTaskDelay(pdMS_TO_TICKS(1000)); gpio_set_level(LED_PIN, 0); vTaskDelay(pdMS_TO_TICKS(1000)); } } `; const DEFAULT_PY_CONTENT = `import RPi.GPIO as GPIO import time LED_PIN = 17 GPIO.setmode(GPIO.BCM) GPIO.setup(LED_PIN, GPIO.OUT) try: while True: GPIO.output(LED_PIN, GPIO.HIGH) time.sleep(1) GPIO.output(LED_PIN, GPIO.LOW) time.sleep(1) except KeyboardInterrupt: GPIO.cleanup() `; const DEFAULT_FILE: WorkspaceFile = { id: MAIN_ID, name: 'sketch.ino', content: DEFAULT_INO_CONTENT, modified: false, }; /** Default file group for the initial Arduino Uno board */ const DEFAULT_GROUP_ID = 'group-arduino-uno'; /** * Editor file group id for a programmable custom-chip's program. * * A custom chip that loads a ROM / runs a user program (a CPU emulator such * as the Z80 or 8080) keeps that program (`larson.s`, `chaser.c`, …) in its * OWN file group, exactly like each board owns one. The file explorer renders * it as a separate collapsible section, so the chip's program never gets * mixed into the board's sketch. Behaviour/driver chips (a servo driver, a * sensor) and predefined chips carry no program file and get no group — they * are edited in the chip designer instead. */ export const chipFileGroupId = (chipId: string): string => `group-chip-${chipId}`; /** Prefix shared by every chip program group — used to sweep stale ones. */ export const CHIP_GROUP_PREFIX = 'group-chip-'; /** * Editor view layout. Lets the user collapse either pane to give the chat * (right-docked) more breathing room, or to focus on one half of the * workflow. */ export type EditorViewMode = 'code' | 'circuit' | 'both'; interface EditorState { files: WorkspaceFile[]; activeFileId: string; openFileIds: string[]; /** When set, the editor shows a READ-ONLY `libraries.json` view of this * board's library manifest (board.libraries) instead of the active file. * Cleared whenever a real file is opened/activated. Managed by the explorer's * libraries.json entry; the Library Manager modal is what edits the manifest. */ manifestViewBoardId: string | null; setManifestView: (boardId: string | null) => void; theme: 'vs-dark' | 'light'; fontSize: number; viewMode: EditorViewMode; setViewMode: (mode: EditorViewMode) => void; // ── File groups (one per board) ────────────────────────────────────────── /** Map of groupId → WorkspaceFile[]. Stored as plain object for Zustand. */ fileGroups: Record; /** Active group (determines which board's files are shown in the editor). */ activeGroupId: string; /** Active file within the active group */ activeGroupFileId: Record; /** Open file IDs within each group */ openGroupFileIds: Record; // File operations (operate on active group) createFile: (name: string) => string; deleteFile: (id: string) => void; renameFile: (id: string, newName: string) => void; setFileContent: (id: string, content: string) => void; markFileSaved: (id: string) => void; openFile: (id: string) => void; closeFile: (id: string) => void; setActiveFile: (id: string) => void; /** Load a full set of files (e.g. when loading a saved project) */ loadFiles: (files: { name: string; content: string }[]) => void; // File group management createFileGroup: ( groupId: string, languageModeOrFiles?: string | { name: string; content: string }[], ) => void; deleteFileGroup: (groupId: string) => void; setActiveGroup: (groupId: string) => void; getGroupFiles: (groupId: string) => WorkspaceFile[]; updateGroupFile: (groupId: string, fileId: string, content: string) => void; /** Replace ALL file groups atomically (used when loading a saved project). */ replaceFileGroups: (groups: Record) => void; // Settings setTheme: (theme: 'vs-dark' | 'light') => void; setFontSize: (size: number) => void; // Dirty flag — tracks whether code changed since last compilation codeChangedSinceLastCompile: boolean; markCompiled: () => void; // Legacy compat — sets content of the active file setCode: (code: string) => void; } export const useEditorStore = create((set, get) => ({ files: [DEFAULT_FILE], activeFileId: MAIN_ID, openFileIds: [MAIN_ID], manifestViewBoardId: null, setManifestView: (boardId: string | null) => set({ manifestViewBoardId: boardId }), theme: 'vs-dark', fontSize: 14, viewMode: 'both', setViewMode: (mode) => set({ viewMode: mode }), // File groups — initial state has one group for the default Arduino Uno board fileGroups: { [DEFAULT_GROUP_ID]: [DEFAULT_FILE], }, activeGroupId: DEFAULT_GROUP_ID, activeGroupFileId: { [DEFAULT_GROUP_ID]: MAIN_ID }, openGroupFileIds: { [DEFAULT_GROUP_ID]: [MAIN_ID] }, codeChangedSinceLastCompile: true, markCompiled: () => set({ codeChangedSinceLastCompile: false }), // ── File operations (legacy API — operate on active group) ────────────── createFile: (name: string) => { const id = generateUUID(); const newFile: WorkspaceFile = { id, name, content: '', modified: false }; set((s) => { const groupId = s.activeGroupId; const groupFiles = [...(s.fileGroups[groupId] ?? []), newFile]; return { // Legacy flat list (mirrors active group) files: [...s.files, newFile], openFileIds: [...s.openFileIds, id], activeFileId: id, // Group-aware state fileGroups: { ...s.fileGroups, [groupId]: groupFiles }, openGroupFileIds: { ...s.openGroupFileIds, [groupId]: [...(s.openGroupFileIds[groupId] ?? []), id], }, activeGroupFileId: { ...s.activeGroupFileId, [groupId]: id }, }; }); return id; }, deleteFile: (id: string) => { set((s) => { const groupId = s.activeGroupId; const files = s.files.filter((f) => f.id !== id); const openFileIds = s.openFileIds.filter((fid) => fid !== id); let activeFileId = s.activeFileId; if (activeFileId === id) { const idx = s.openFileIds.indexOf(id); activeFileId = openFileIds[idx] ?? openFileIds[idx - 1] ?? openFileIds[0] ?? files[0]?.id ?? ''; } const groupFiles = (s.fileGroups[groupId] ?? []).filter((f) => f.id !== id); const groupOpenIds = (s.openGroupFileIds[groupId] ?? []).filter((fid) => fid !== id); return { files, openFileIds, activeFileId, fileGroups: { ...s.fileGroups, [groupId]: groupFiles }, openGroupFileIds: { ...s.openGroupFileIds, [groupId]: groupOpenIds }, activeGroupFileId: { ...s.activeGroupFileId, [groupId]: activeFileId }, }; }); }, renameFile: (id: string, newName: string) => { set((s) => { const groupId = s.activeGroupId; const mapper = (f: WorkspaceFile) => f.id === id ? { ...f, name: newName, modified: true } : f; return { files: s.files.map(mapper), fileGroups: { ...s.fileGroups, [groupId]: (s.fileGroups[groupId] ?? []).map(mapper) }, }; }); }, setFileContent: (id: string, content: string) => { set((s) => { const groupId = s.activeGroupId; const mapper = (f: WorkspaceFile) => (f.id === id ? { ...f, content, modified: true } : f); return { files: s.files.map(mapper), fileGroups: { ...s.fileGroups, [groupId]: (s.fileGroups[groupId] ?? []).map(mapper) }, codeChangedSinceLastCompile: true, }; }); }, markFileSaved: (id: string) => { set((s) => { const groupId = s.activeGroupId; const mapper = (f: WorkspaceFile) => (f.id === id ? { ...f, modified: false } : f); return { files: s.files.map(mapper), fileGroups: { ...s.fileGroups, [groupId]: (s.fileGroups[groupId] ?? []).map(mapper) }, }; }); }, openFile: (id: string) => { set((s) => { const groupId = s.activeGroupId; const groupOpenIds = s.openGroupFileIds[groupId] ?? []; return { openFileIds: s.openFileIds.includes(id) ? s.openFileIds : [...s.openFileIds, id], activeFileId: id, manifestViewBoardId: null, // opening a real file exits the libraries.json view openGroupFileIds: { ...s.openGroupFileIds, [groupId]: groupOpenIds.includes(id) ? groupOpenIds : [...groupOpenIds, id], }, activeGroupFileId: { ...s.activeGroupFileId, [groupId]: id }, }; }); }, closeFile: (id: string) => { set((s) => { const groupId = s.activeGroupId; const openFileIds = s.openFileIds.filter((fid) => fid !== id); let activeFileId = s.activeFileId; if (activeFileId === id) { const idx = s.openFileIds.indexOf(id); activeFileId = openFileIds[idx] ?? openFileIds[idx - 1] ?? openFileIds[0] ?? ''; } const groupOpenIds = (s.openGroupFileIds[groupId] ?? []).filter((fid) => fid !== id); return { openFileIds, activeFileId, openGroupFileIds: { ...s.openGroupFileIds, [groupId]: groupOpenIds }, activeGroupFileId: { ...s.activeGroupFileId, [groupId]: activeFileId }, }; }); }, setActiveFile: (id: string) => { set((s) => { const groupId = s.activeGroupId; return { activeFileId: id, manifestViewBoardId: null, // activating a real file exits the libraries.json view activeGroupFileId: { ...s.activeGroupFileId, [groupId]: id }, }; }); }, loadFiles: (incoming: { name: string; content: string }[]) => { const files: WorkspaceFile[] = incoming.map((f, i) => ({ id: i === 0 ? MAIN_ID : generateUUID(), name: f.name, content: f.content, modified: false, })); const firstId = files[0]?.id ?? MAIN_ID; set((s) => { const groupId = s.activeGroupId; return { files, activeFileId: firstId, openFileIds: [firstId], fileGroups: { ...s.fileGroups, [groupId]: files }, activeGroupFileId: { ...s.activeGroupFileId, [groupId]: firstId }, openGroupFileIds: { ...s.openGroupFileIds, [groupId]: [firstId] }, }; }); }, // ── File group management ───────────────────────────────────────────────── createFileGroup: ( groupId: string, languageModeOrFiles?: string | { name: string; content: string }[], ) => { set((s) => { if (s.fileGroups[groupId]) return s; // already exists // Resolve overloaded parameter const initialFiles = Array.isArray(languageModeOrFiles) ? languageModeOrFiles : undefined; const languageMode = typeof languageModeOrFiles === 'string' ? languageModeOrFiles : undefined; let files: WorkspaceFile[]; if (initialFiles && initialFiles.length > 0) { files = initialFiles.map((f, i) => ({ id: i === 0 ? `${groupId}-main` : generateUUID(), name: f.name, content: f.content, modified: false, })); } else { // Determine default file by group name convention or language mode. // All QEMU-Linux boards (Pi Zero/1/2/3/4/5 plus overlay piFamily // kinds like the UNIHIKER) default to script.py. Group ids follow // `group-`; the first board of a kind uses the kind as its // id, later instances append '-N'. Test the FULL id first — kinds // themselves end in digits ('raspberry-pi-3'), so stripping the // numeric suffix unconditionally would mangle them ('raspberry-pi' // matched nothing and Pis regressed to sketch.ino). const boardIdPart = groupId.replace(/^group-/, ''); const isPi = isPiBoardKind(boardIdPart) || isPiBoardKind(boardIdPart.replace(/-\d+$/, '')); const isMicroPython = languageMode === 'micropython'; const mainId = `${groupId}-main`; let fileName: string; let content: string; const isEsp32 = groupId.includes('esp32'); if (languageMode === 'espidf') { fileName = 'main.c'; content = DEFAULT_ESPIDF_CONTENT; } else if (isMicroPython && isEsp32) { fileName = 'main.py'; content = DEFAULT_ESP32_MICROPYTHON_CONTENT; } else if (isMicroPython) { fileName = 'main.py'; content = DEFAULT_MICROPYTHON_CONTENT; } else if (isPi) { fileName = 'script.py'; content = DEFAULT_PY_CONTENT; } else { fileName = 'sketch.ino'; content = DEFAULT_INO_CONTENT; } files = [{ id: mainId, name: fileName, content, modified: false }]; } const firstId = files[0]?.id ?? `${groupId}-main`; return { fileGroups: { ...s.fileGroups, [groupId]: files }, activeGroupFileId: { ...s.activeGroupFileId, [groupId]: firstId }, openGroupFileIds: { ...s.openGroupFileIds, [groupId]: [firstId] }, }; }); }, deleteFileGroup: (groupId: string) => { set((s) => { const { [groupId]: _removed, ...rest } = s.fileGroups; const { [groupId]: _a, ...restActive } = s.activeGroupFileId; const { [groupId]: _o, ...restOpen } = s.openGroupFileIds; return { fileGroups: rest, activeGroupFileId: restActive, openGroupFileIds: restOpen, }; }); }, setActiveGroup: (groupId: string) => { set((s) => { const groupFiles = s.fileGroups[groupId] ?? []; const activeFileId = s.activeGroupFileId[groupId] ?? groupFiles[0]?.id ?? ''; const openFileIds = s.openGroupFileIds[groupId] ?? (groupFiles[0] ? [groupFiles[0].id] : []); return { activeGroupId: groupId, files: groupFiles, activeFileId, openFileIds, }; }); }, getGroupFiles: (groupId: string) => { return get().fileGroups[groupId] ?? []; }, updateGroupFile: (groupId: string, fileId: string, content: string) => { set((s) => { const groupFiles = (s.fileGroups[groupId] ?? []).map((f) => f.id === fileId ? { ...f, content, modified: true } : f, ); return { fileGroups: { ...s.fileGroups, [groupId]: groupFiles } }; }); }, replaceFileGroups: (groups) => { const fileGroups: Record = {}; const activeGroupFileId: Record = {}; const openGroupFileIds: Record = {}; for (const [gid, files] of Object.entries(groups)) { const wsFiles: WorkspaceFile[] = files.map((f, i) => ({ id: i === 0 ? `${gid}-main` : generateUUID(), name: f.name, content: f.content, modified: false, })); fileGroups[gid] = wsFiles; const firstId = wsFiles[0]?.id ?? `${gid}-main`; activeGroupFileId[gid] = firstId; openGroupFileIds[gid] = wsFiles[0] ? [firstId] : []; } set((s) => { const activeGroupId = fileGroups[s.activeGroupId] ? s.activeGroupId : (Object.keys(fileGroups)[0] ?? s.activeGroupId); const groupFiles = fileGroups[activeGroupId] ?? []; return { fileGroups, activeGroupFileId, openGroupFileIds, activeGroupId, // Mirror legacy flat fields to the active group files: groupFiles, activeFileId: activeGroupFileId[activeGroupId] ?? '', openFileIds: openGroupFileIds[activeGroupId] ?? [], }; }); }, // ── Settings ────────────────────────────────────────────────────────────── setTheme: (theme) => set({ theme }), setFontSize: (fontSize) => set({ fontSize }), // Legacy: sets content of active file setCode: (code: string) => { const { activeFileId, setFileContent } = get(); if (activeFileId) setFileContent(activeFileId, code); }, }));