import { create } from 'zustand'; import { generateUUID } from '../utils/uuid'; import { isPiBoardKind } from '../types/board'; import { getProBoard } from '../lib/proBoardRegistry'; export interface WorkspaceFile { id: string; name: string; content: string; modified: boolean; } const MAIN_ID = 'main-sketch'; /** Trim, collapse slashes/backslashes, strip leading/trailing '/' and any * '.'/'..' segments — folder paths are always workspace-relative. */ export function normalizeFolderPath(path: string): string { return path .trim() .replace(/\\/g, '/') .split('/') .map((seg) => seg.trim()) .filter((seg) => seg && seg !== '.' && seg !== '..') .join('/'); } 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) `; // The shipped ESP32-family MicroPython is v1.28.0 (IDF 5.x drivers — the // same generation the compile pipeline and the JS engines are validated // against), so Pin.toggle() and friends exist now; this seed just stays on // the lowest-common API. History: #122 (v1.20 had no Pin.toggle). 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; /** Desktop file-explorer pane. Lives in the store (not EditorPage state) so * the header's View menu can show its checkmark and toggle it — the * toolbar's own segmented toggle hides on narrow bars. */ explorerOpen: boolean; setExplorerOpen: (open: boolean) => void; toggleExplorer: () => 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; // ── Folders ────────────────────────────────────────────────────────────── /** * Folders exist implicitly through file names containing '/' * ("apps/badge/__init__.py" lives in apps/badge). This map only tracks * EMPTY folders per group (created but not yet holding a file) so they * survive in the explorer until a file lands in them. Paths are * '/'-separated with no leading/trailing slash. */ folderGroups: Record; /** Create a folder (in the active group). No-op if it already exists. */ createFolder: (path: string) => void; /** Delete a folder (active group): removes every file under it and any * tracked empty subfolders. */ deleteFolder: (path: string) => void; // 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). * `folders` restores the tracked EMPTY folders per group (optional — * folders holding files rebuild themselves from the file name prefixes). */ replaceFileGroups: ( groups: Record, folders?: 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 }), explorerOpen: true, setExplorerOpen: (open) => set({ explorerOpen: open }), toggleExplorer: () => set((s) => ({ explorerOpen: !s.explorerOpen })), // File groups — initial state has one group for the default Arduino Uno board fileGroups: { [DEFAULT_GROUP_ID]: [DEFAULT_FILE], }, folderGroups: {}, activeGroupId: DEFAULT_GROUP_ID, activeGroupFileId: { [DEFAULT_GROUP_ID]: MAIN_ID }, openGroupFileIds: { [DEFAULT_GROUP_ID]: [MAIN_ID] }, codeChangedSinceLastCompile: true, markCompiled: () => set({ codeChangedSinceLastCompile: false }), // ── Folder operations (operate on active group) ───────────────────────── createFolder: (path: string) => { const clean = normalizeFolderPath(path); if (!clean) return; set((s) => { const groupId = s.activeGroupId; const folders = s.folderGroups[groupId] ?? []; const files = s.fileGroups[groupId] ?? []; const exists = folders.includes(clean) || files.some((f) => f.name.startsWith(clean + '/')); if (exists) return {}; return { folderGroups: { ...s.folderGroups, [groupId]: [...folders, clean] } }; }); }, deleteFolder: (path: string) => { const clean = normalizeFolderPath(path); if (!clean) return; set((s) => { const groupId = s.activeGroupId; const prefix = clean + '/'; const doomed = new Set( (s.fileGroups[groupId] ?? []).filter((f) => f.name.startsWith(prefix)).map((f) => f.id), ); const files = s.files.filter((f) => !doomed.has(f.id)); const openFileIds = s.openFileIds.filter((fid) => !doomed.has(fid)); let activeFileId = s.activeFileId; if (doomed.has(activeFileId)) activeFileId = openFileIds[0] ?? files[0]?.id ?? ''; const groupFiles = (s.fileGroups[groupId] ?? []).filter((f) => !doomed.has(f.id)); const groupOpenIds = (s.openGroupFileIds[groupId] ?? []).filter((fid) => !doomed.has(fid)); const folders = (s.folderGroups[groupId] ?? []).filter( (p) => p !== clean && !p.startsWith(prefix), ); return { files, openFileIds, activeFileId, fileGroups: { ...s.fileGroups, [groupId]: groupFiles }, openGroupFileIds: { ...s.openGroupFileIds, [groupId]: groupOpenIds }, activeGroupFileId: { ...s.activeGroupFileId, [groupId]: activeFileId }, folderGroups: { ...s.folderGroups, [groupId]: folders }, }; }); }, // ── 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]; // A file landing inside a tracked empty folder materialises it — the // folder now exists through the file's path prefix. const folders = (s.folderGroups[groupId] ?? []).filter((p) => !name.startsWith(p + '/')); 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 }, folderGroups: { ...s.folderGroups, [groupId]: folders }, }; }); 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; // ESP32 by id ('group-esp32-c3') or by the overlay board's declared // family — an overlay kind can be an ESP32 with no 'esp32' in its name // (m5stack-core, cardputer-adv), and those used to be seeded the Pico's // `Pin(25)` blink, a dead pin on both. const isEsp32 = groupId.includes('esp32') || getProBoard(boardIdPart)?.esp32Family !== undefined || getProBoard(boardIdPart.replace(/-\d+$/, ''))?.esp32Family !== undefined; 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; const { [groupId]: _f, ...restFolders } = s.folderGroups; // Never leave activeGroupId dangling at the deleted group: Monaco (and // every agent write_file) keeps targeting it, while compile reads the // board's group — two same-named sketch.ino files silently diverge and // the build ships the WRONG one (the "leftover template Blink compiled // instead of my code" class). Re-point at any surviving group. const nextActive = s.activeGroupId === groupId ? Object.keys(rest)[0] ?? '' : s.activeGroupId; return { fileGroups: rest, activeGroupFileId: restActive, openGroupFileIds: restOpen, folderGroups: restFolders, activeGroupId: nextActive, }; }); }, 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, folders) => { 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, folderGroups: folders ?? {}, // 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); }, }));