2026-03-03 10:20:49 +07:00
|
|
|
import { create } from 'zustand';
|
2026-05-05 23:41:30 +07:00
|
|
|
import { generateUUID } from '../utils/uuid';
|
2026-07-29 00:43:20 +07:00
|
|
|
import { isPiBoardKind } from '../types/board';
|
2026-03-03 10:20:49 +07:00
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
export interface WorkspaceFile {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
content: string;
|
|
|
|
|
modified: boolean;
|
2026-03-03 10:20:49 +07:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
const MAIN_ID = 'main-sketch';
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
const DEFAULT_INO_CONTENT = `// Arduino Blink Example
|
2026-03-03 10:20:49 +07:00
|
|
|
void setup() {
|
|
|
|
|
pinMode(LED_BUILTIN, OUTPUT);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void loop() {
|
|
|
|
|
digitalWrite(LED_BUILTIN, HIGH);
|
|
|
|
|
delay(1000);
|
|
|
|
|
digitalWrite(LED_BUILTIN, LOW);
|
|
|
|
|
delay(1000);
|
2026-03-13 09:39:04 +07:00
|
|
|
}`;
|
|
|
|
|
|
2026-03-30 07:27:41 +07:00
|
|
|
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)
|
|
|
|
|
`;
|
|
|
|
|
|
2026-04-29 10:21:26 +07:00
|
|
|
// 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
|
2026-03-30 09:12:24 +07:00
|
|
|
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
|
2026-04-29 10:21:26 +07:00
|
|
|
state = False
|
2026-03-30 09:12:24 +07:00
|
|
|
|
|
|
|
|
while True:
|
2026-04-29 10:21:26 +07:00
|
|
|
state = not state
|
|
|
|
|
led.value(state)
|
2026-03-30 09:12:24 +07:00
|
|
|
time.sleep(1)
|
|
|
|
|
`;
|
|
|
|
|
|
feat(esp32): pure ESP-IDF language mode for the ESP32 family (#139)
Adds a third entry to the board language selector next to Arduino C++
and MicroPython: ESP-IDF. In this mode the user writes a plain ESP-IDF
project — app_main() entry point, FreeRTOS + driver APIs — and the
backend compiles it through the same ESP-IDF toolchain it already uses
for ESP32 Arduino sketches, just without the arduino-esp32 component.
Backend:
- CompileRequest.language ('espidf') threaded through the sync + async
compile paths and folded into the dedup job key (language='arduino'
and omitted hash identically so old clients keep dedupping).
- espidf_compiler: pure_idf flag. User files are written into main/
as-is (no Arduino.h wrap, no velxio_compat.h, Arduino library
resolution skipped), ARDUINO_ESP32_PATH is dropped from the build env
and VELXIO_PURE_SKETCH raised so the template CMake compiles the
user's own sources via a glob branch. Pure builds get their own
persistent build-dir variant through the eff_hash fold.
- QEMU WiFi compat for IDF-style code: esp_wifi.h/esp_wifi_init
detection sets has_wifi, and literal #define SSID/PASS plus
wifi_config_t designated initializers are normalized to the QEMU AP.
- CONFIG_ARDUINO_* lines are stripped from sdkconfig.defaults in pure
mode (the symbols don't exist without the arduino component).
Frontend:
- LanguageMode gains 'espidf'; BOARD_SUPPORTS_ESPIDF covers the ESP32
family (Xtensa, S3, C3). Toolbar shows the option only for those.
- Switching modes seeds a main.c blink skeleton (app_main + gpio
driver), mirroring the MicroPython main.py flow.
- compileCode sends language='espidf'; run/stop paths are unchanged
(the QEMU worker consumes the same merged flash image).
- New gallery example: esp32-idf-blink (LED + resistor on GPIO 2).
Tests: unit coverage for the build-env switch, IDF wifi normalization,
job-key variance, file-group seeding and the new example; verified
end-to-end in a container from the prod image (pure build produces a
bootable flash image; Arduino-mode build unchanged, same variant hash).
2026-07-24 11:37:01 +07:00
|
|
|
// 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 <stdio.h>
|
|
|
|
|
#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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
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,
|
2026-03-06 20:14:50 +07:00
|
|
|
modified: false,
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
/** Default file group for the initial Arduino Uno board */
|
|
|
|
|
const DEFAULT_GROUP_ID = 'group-arduino-uno';
|
|
|
|
|
|
feat(custom-chip): program lives in its own editor group, not the board sketch
A programmable custom-chip (a CPU emulator that runs a ROM/program, e.g. the
Z80 or 8080) now keeps its program (larson.s, chaser.c, ...) in a dedicated
editor file group — group-chip-<chipId> — rendered as its own collapsible
section in the file explorer, exactly like each board owns its sketch group.
Behaviour/driver chips and predefined chips carry no programFile and get no
group; they stay editable only in the chip designer.
Fixes two reported issues on the Z80 examples:
- /example/z80-larson-no-board: the board-less chip example now opens its
program (larson.s) as the active group, editable on the left — previously
the editor showed but no file appeared.
- /example/z80-led-chaser-c: the chip program (chaser.c) no longer shows as
a sibling tab inside the Arduino sketch group; it sits in its own chip
section instead. The board group shows only sketch.ino.
Details:
- useEditorStore: chipFileGroupId()/CHIP_GROUP_PREFIX helpers.
- loadExample: seedChipProgramGroups() routes each chip's programFile into its
own group (seeded from the example files), sweeps stale chip groups, keeps
the program OUT of the board group, and for a board-less chip example makes
the chip group active so the program is the editable file shown.
- EditorToolbar.prepareCustomChips: resolves the program from the chip's own
group (falls back to board files for older projects) before assembling ROM.
- FileExplorer: renders one collapsible section per programmable chip with an
IC icon; clicking switches the editor to the chip group. Lazy-creates a
group for chips dropped on the canvas.
- projectPayload + vlxFile: serialise chip groups alongside board groups and
include them in the dirty-check hash, so chip-program edits persist on
save / autosave / .vlx export and round-trip via replaceFileGroups on load.
- Regression tests for board-less + board+chip routing and stale-group sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 03:07:56 +07:00
|
|
|
/**
|
|
|
|
|
* 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-';
|
|
|
|
|
|
2026-05-08 10:53:27 +07:00
|
|
|
/**
|
|
|
|
|
* 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';
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
interface EditorState {
|
|
|
|
|
files: WorkspaceFile[];
|
|
|
|
|
activeFileId: string;
|
|
|
|
|
openFileIds: string[];
|
2026-06-09 20:49:14 +07:00
|
|
|
/** 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;
|
2026-03-06 20:14:50 +07:00
|
|
|
theme: 'vs-dark' | 'light';
|
|
|
|
|
fontSize: number;
|
2026-05-08 10:53:27 +07:00
|
|
|
viewMode: EditorViewMode;
|
|
|
|
|
setViewMode: (mode: EditorViewMode) => void;
|
2026-03-06 20:14:50 +07:00
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
// ── File groups (one per board) ──────────────────────────────────────────
|
|
|
|
|
/** Map of groupId → WorkspaceFile[]. Stored as plain object for Zustand. */
|
|
|
|
|
fileGroups: Record<string, WorkspaceFile[]>;
|
|
|
|
|
/** Active group (determines which board's files are shown in the editor). */
|
|
|
|
|
activeGroupId: string;
|
|
|
|
|
/** Active file within the active group */
|
|
|
|
|
activeGroupFileId: Record<string, string>;
|
|
|
|
|
/** Open file IDs within each group */
|
|
|
|
|
openGroupFileIds: Record<string, string[]>;
|
|
|
|
|
|
|
|
|
|
// File operations (operate on active group)
|
2026-03-06 20:14:50 +07:00
|
|
|
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;
|
2026-03-13 09:39:04 +07:00
|
|
|
/** Load a full set of files (e.g. when loading a saved project) */
|
2026-03-06 20:14:50 +07:00
|
|
|
loadFiles: (files: { name: string; content: string }[]) => void;
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
// File group management
|
2026-04-22 02:45:45 +07:00
|
|
|
createFileGroup: (
|
|
|
|
|
groupId: string,
|
|
|
|
|
languageModeOrFiles?: string | { name: string; content: string }[],
|
|
|
|
|
) => void;
|
2026-03-13 09:39:04 +07:00
|
|
|
deleteFileGroup: (groupId: string) => void;
|
|
|
|
|
setActiveGroup: (groupId: string) => void;
|
|
|
|
|
getGroupFiles: (groupId: string) => WorkspaceFile[];
|
|
|
|
|
updateGroupFile: (groupId: string, fileId: string, content: string) => void;
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
/** Replace ALL file groups atomically (used when loading a saved project). */
|
|
|
|
|
replaceFileGroups: (groups: Record<string, { name: string; content: string }[]>) => void;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
// Settings
|
|
|
|
|
setTheme: (theme: 'vs-dark' | 'light') => void;
|
|
|
|
|
setFontSize: (size: number) => void;
|
|
|
|
|
|
2026-03-29 11:46:07 +07:00
|
|
|
// Dirty flag — tracks whether code changed since last compilation
|
|
|
|
|
codeChangedSinceLastCompile: boolean;
|
|
|
|
|
markCompiled: () => void;
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
// Legacy compat — sets content of the active file
|
|
|
|
|
setCode: (code: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const useEditorStore = create<EditorState>((set, get) => ({
|
|
|
|
|
files: [DEFAULT_FILE],
|
|
|
|
|
activeFileId: MAIN_ID,
|
|
|
|
|
openFileIds: [MAIN_ID],
|
2026-06-09 20:49:14 +07:00
|
|
|
manifestViewBoardId: null,
|
|
|
|
|
setManifestView: (boardId: string | null) => set({ manifestViewBoardId: boardId }),
|
2026-03-03 10:20:49 +07:00
|
|
|
theme: 'vs-dark',
|
|
|
|
|
fontSize: 14,
|
2026-05-08 10:53:27 +07:00
|
|
|
viewMode: 'both',
|
|
|
|
|
setViewMode: (mode) => set({ viewMode: mode }),
|
2026-03-03 10:20:49 +07:00
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
// 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] },
|
|
|
|
|
|
2026-03-29 11:46:07 +07:00
|
|
|
codeChangedSinceLastCompile: true,
|
|
|
|
|
markCompiled: () => set({ codeChangedSinceLastCompile: false }),
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
// ── File operations (legacy API — operate on active group) ──────────────
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
createFile: (name: string) => {
|
2026-05-05 23:41:30 +07:00
|
|
|
const id = generateUUID();
|
2026-03-06 20:14:50 +07:00
|
|
|
const newFile: WorkspaceFile = { id, name, content: '', modified: false };
|
2026-03-13 09:39:04 +07:00
|
|
|
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 },
|
2026-04-22 02:45:45 +07:00
|
|
|
openGroupFileIds: {
|
|
|
|
|
...s.openGroupFileIds,
|
|
|
|
|
[groupId]: [...(s.openGroupFileIds[groupId] ?? []), id],
|
|
|
|
|
},
|
2026-03-13 09:39:04 +07:00
|
|
|
activeGroupFileId: { ...s.activeGroupFileId, [groupId]: id },
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-03-06 20:14:50 +07:00
|
|
|
return id;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
deleteFile: (id: string) => {
|
|
|
|
|
set((s) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
const groupId = s.activeGroupId;
|
2026-03-06 20:14:50 +07:00
|
|
|
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 =
|
2026-04-22 02:45:45 +07:00
|
|
|
openFileIds[idx] ?? openFileIds[idx - 1] ?? openFileIds[0] ?? files[0]?.id ?? '';
|
2026-03-06 20:14:50 +07:00
|
|
|
}
|
2026-03-13 09:39:04 +07:00
|
|
|
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 },
|
|
|
|
|
};
|
2026-03-06 20:14:50 +07:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
renameFile: (id: string, newName: string) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
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) },
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-03-06 20:14:50 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setFileContent: (id: string, content: string) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
set((s) => {
|
|
|
|
|
const groupId = s.activeGroupId;
|
2026-04-22 02:45:45 +07:00
|
|
|
const mapper = (f: WorkspaceFile) => (f.id === id ? { ...f, content, modified: true } : f);
|
2026-03-13 09:39:04 +07:00
|
|
|
return {
|
|
|
|
|
files: s.files.map(mapper),
|
|
|
|
|
fileGroups: { ...s.fileGroups, [groupId]: (s.fileGroups[groupId] ?? []).map(mapper) },
|
2026-03-29 11:46:07 +07:00
|
|
|
codeChangedSinceLastCompile: true,
|
2026-03-13 09:39:04 +07:00
|
|
|
};
|
|
|
|
|
});
|
2026-03-06 20:14:50 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
markFileSaved: (id: string) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
set((s) => {
|
|
|
|
|
const groupId = s.activeGroupId;
|
2026-04-22 02:45:45 +07:00
|
|
|
const mapper = (f: WorkspaceFile) => (f.id === id ? { ...f, modified: false } : f);
|
2026-03-13 09:39:04 +07:00
|
|
|
return {
|
|
|
|
|
files: s.files.map(mapper),
|
|
|
|
|
fileGroups: { ...s.fileGroups, [groupId]: (s.fileGroups[groupId] ?? []).map(mapper) },
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-03-06 20:14:50 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
openFile: (id: string) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
set((s) => {
|
|
|
|
|
const groupId = s.activeGroupId;
|
|
|
|
|
const groupOpenIds = s.openGroupFileIds[groupId] ?? [];
|
|
|
|
|
return {
|
|
|
|
|
openFileIds: s.openFileIds.includes(id) ? s.openFileIds : [...s.openFileIds, id],
|
|
|
|
|
activeFileId: id,
|
2026-06-09 20:49:14 +07:00
|
|
|
manifestViewBoardId: null, // opening a real file exits the libraries.json view
|
2026-03-13 09:39:04 +07:00
|
|
|
openGroupFileIds: {
|
|
|
|
|
...s.openGroupFileIds,
|
|
|
|
|
[groupId]: groupOpenIds.includes(id) ? groupOpenIds : [...groupOpenIds, id],
|
|
|
|
|
},
|
|
|
|
|
activeGroupFileId: { ...s.activeGroupFileId, [groupId]: id },
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-03-06 20:14:50 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
closeFile: (id: string) => {
|
|
|
|
|
set((s) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
const groupId = s.activeGroupId;
|
2026-03-06 20:14:50 +07:00
|
|
|
const openFileIds = s.openFileIds.filter((fid) => fid !== id);
|
|
|
|
|
let activeFileId = s.activeFileId;
|
|
|
|
|
if (activeFileId === id) {
|
|
|
|
|
const idx = s.openFileIds.indexOf(id);
|
2026-04-22 02:45:45 +07:00
|
|
|
activeFileId = openFileIds[idx] ?? openFileIds[idx - 1] ?? openFileIds[0] ?? '';
|
2026-03-06 20:14:50 +07:00
|
|
|
}
|
2026-03-13 09:39:04 +07:00
|
|
|
const groupOpenIds = (s.openGroupFileIds[groupId] ?? []).filter((fid) => fid !== id);
|
|
|
|
|
return {
|
|
|
|
|
openFileIds,
|
|
|
|
|
activeFileId,
|
|
|
|
|
openGroupFileIds: { ...s.openGroupFileIds, [groupId]: groupOpenIds },
|
|
|
|
|
activeGroupFileId: { ...s.activeGroupFileId, [groupId]: activeFileId },
|
|
|
|
|
};
|
2026-03-06 20:14:50 +07:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
setActiveFile: (id: string) => {
|
|
|
|
|
set((s) => {
|
|
|
|
|
const groupId = s.activeGroupId;
|
|
|
|
|
return {
|
|
|
|
|
activeFileId: id,
|
2026-06-09 20:49:14 +07:00
|
|
|
manifestViewBoardId: null, // activating a real file exits the libraries.json view
|
2026-03-13 09:39:04 +07:00
|
|
|
activeGroupFileId: { ...s.activeGroupFileId, [groupId]: id },
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-03-06 20:14:50 +07:00
|
|
|
|
|
|
|
|
loadFiles: (incoming: { name: string; content: string }[]) => {
|
|
|
|
|
const files: WorkspaceFile[] = incoming.map((f, i) => ({
|
2026-05-05 23:41:30 +07:00
|
|
|
id: i === 0 ? MAIN_ID : generateUUID(),
|
2026-03-06 20:14:50 +07:00
|
|
|
name: f.name,
|
|
|
|
|
content: f.content,
|
|
|
|
|
modified: false,
|
|
|
|
|
}));
|
|
|
|
|
const firstId = files[0]?.id ?? MAIN_ID;
|
2026-03-13 09:39:04 +07:00
|
|
|
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] },
|
|
|
|
|
};
|
2026-03-06 20:14:50 +07:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
// ── File group management ─────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
createFileGroup: (
|
|
|
|
|
groupId: string,
|
|
|
|
|
languageModeOrFiles?: string | { name: string; content: string }[],
|
|
|
|
|
) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
set((s) => {
|
|
|
|
|
if (s.fileGroups[groupId]) return s; // already exists
|
|
|
|
|
|
2026-03-30 07:27:41 +07:00
|
|
|
// Resolve overloaded parameter
|
|
|
|
|
const initialFiles = Array.isArray(languageModeOrFiles) ? languageModeOrFiles : undefined;
|
2026-04-22 02:45:45 +07:00
|
|
|
const languageMode =
|
|
|
|
|
typeof languageModeOrFiles === 'string' ? languageModeOrFiles : undefined;
|
2026-03-30 07:27:41 +07:00
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
let files: WorkspaceFile[];
|
|
|
|
|
if (initialFiles && initialFiles.length > 0) {
|
|
|
|
|
files = initialFiles.map((f, i) => ({
|
2026-05-05 23:41:30 +07:00
|
|
|
id: i === 0 ? `${groupId}-main` : generateUUID(),
|
2026-03-13 09:39:04 +07:00
|
|
|
name: f.name,
|
|
|
|
|
content: f.content,
|
|
|
|
|
modified: false,
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
2026-06-15 21:57:10 +07:00
|
|
|
// Determine default file by group name convention or language mode.
|
2026-07-29 00:43:20 +07:00
|
|
|
// 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-<boardId>` and the first board of a kind uses the kind as
|
|
|
|
|
// its id ('-N' suffix for later instances), so strip both and ask
|
|
|
|
|
// isPiBoardKind — the same predicate every other Pi code path uses.
|
|
|
|
|
const boardIdPart = groupId.replace(/^group-/, '').replace(/-\d+$/, '');
|
|
|
|
|
const isPi = isPiBoardKind(boardIdPart);
|
2026-03-30 07:27:41 +07:00
|
|
|
const isMicroPython = languageMode === 'micropython';
|
2026-03-13 09:39:04 +07:00
|
|
|
const mainId = `${groupId}-main`;
|
2026-03-30 07:27:41 +07:00
|
|
|
let fileName: string;
|
|
|
|
|
let content: string;
|
2026-03-30 09:12:24 +07:00
|
|
|
const isEsp32 = groupId.includes('esp32');
|
feat(esp32): pure ESP-IDF language mode for the ESP32 family (#139)
Adds a third entry to the board language selector next to Arduino C++
and MicroPython: ESP-IDF. In this mode the user writes a plain ESP-IDF
project — app_main() entry point, FreeRTOS + driver APIs — and the
backend compiles it through the same ESP-IDF toolchain it already uses
for ESP32 Arduino sketches, just without the arduino-esp32 component.
Backend:
- CompileRequest.language ('espidf') threaded through the sync + async
compile paths and folded into the dedup job key (language='arduino'
and omitted hash identically so old clients keep dedupping).
- espidf_compiler: pure_idf flag. User files are written into main/
as-is (no Arduino.h wrap, no velxio_compat.h, Arduino library
resolution skipped), ARDUINO_ESP32_PATH is dropped from the build env
and VELXIO_PURE_SKETCH raised so the template CMake compiles the
user's own sources via a glob branch. Pure builds get their own
persistent build-dir variant through the eff_hash fold.
- QEMU WiFi compat for IDF-style code: esp_wifi.h/esp_wifi_init
detection sets has_wifi, and literal #define SSID/PASS plus
wifi_config_t designated initializers are normalized to the QEMU AP.
- CONFIG_ARDUINO_* lines are stripped from sdkconfig.defaults in pure
mode (the symbols don't exist without the arduino component).
Frontend:
- LanguageMode gains 'espidf'; BOARD_SUPPORTS_ESPIDF covers the ESP32
family (Xtensa, S3, C3). Toolbar shows the option only for those.
- Switching modes seeds a main.c blink skeleton (app_main + gpio
driver), mirroring the MicroPython main.py flow.
- compileCode sends language='espidf'; run/stop paths are unchanged
(the QEMU worker consumes the same merged flash image).
- New gallery example: esp32-idf-blink (LED + resistor on GPIO 2).
Tests: unit coverage for the build-env switch, IDF wifi normalization,
job-key variance, file-group seeding and the new example; verified
end-to-end in a container from the prod image (pure build produces a
bootable flash image; Arduino-mode build unchanged, same variant hash).
2026-07-24 11:37:01 +07:00
|
|
|
if (languageMode === 'espidf') {
|
|
|
|
|
fileName = 'main.c';
|
|
|
|
|
content = DEFAULT_ESPIDF_CONTENT;
|
|
|
|
|
} else if (isMicroPython && isEsp32) {
|
2026-03-30 09:12:24 +07:00
|
|
|
fileName = 'main.py';
|
|
|
|
|
content = DEFAULT_ESP32_MICROPYTHON_CONTENT;
|
|
|
|
|
} else if (isMicroPython) {
|
2026-03-30 07:27:41 +07:00
|
|
|
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 }];
|
2026-03-13 09:39:04 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) =>
|
2026-04-22 02:45:45 +07:00
|
|
|
f.id === fileId ? { ...f, content, modified: true } : f,
|
2026-03-13 09:39:04 +07:00
|
|
|
);
|
|
|
|
|
return { fileGroups: { ...s.fileGroups, [groupId]: groupFiles } };
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
replaceFileGroups: (groups) => {
|
|
|
|
|
const fileGroups: Record<string, WorkspaceFile[]> = {};
|
|
|
|
|
const activeGroupFileId: Record<string, string> = {};
|
|
|
|
|
const openGroupFileIds: Record<string, string[]> = {};
|
|
|
|
|
for (const [gid, files] of Object.entries(groups)) {
|
|
|
|
|
const wsFiles: WorkspaceFile[] = files.map((f, i) => ({
|
2026-05-05 23:41:30 +07:00
|
|
|
id: i === 0 ? `${gid}-main` : generateUUID(),
|
feat: persist multi-board projects + add auto-save
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
|
|
|
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] ?? [],
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
// ── Settings ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-03 10:20:49 +07:00
|
|
|
setTheme: (theme) => set({ theme }),
|
|
|
|
|
setFontSize: (fontSize) => set({ fontSize }),
|
2026-03-06 20:14:50 +07:00
|
|
|
|
|
|
|
|
// Legacy: sets content of active file
|
|
|
|
|
setCode: (code: string) => {
|
|
|
|
|
const { activeFileId, setFileContent } = get();
|
|
|
|
|
if (activeFileId) setFileContent(activeFileId, code);
|
|
|
|
|
},
|
2026-03-03 10:20:49 +07:00
|
|
|
}));
|