velxio/frontend/src/store/useEditorStore.ts

516 lines
18 KiB
TypeScript
Raw Normal View History

import { create } from 'zustand';
fix(frontend): polyfill crypto.randomUUID for non-secure contexts crypto.randomUUID() is only exposed on secure contexts (HTTPS, localhost, 127.0.0.1, ::1). When Velxio is self-hosted and accessed via a LAN IP over plain HTTP (e.g. http://192.168.31.139:3080/), crypto.randomUUID is undefined and any code path that calls it throws TypeError. This silently broke ESP32 simulation start for self-hosters: the frontend generates a UUID for the WS client_id when Run is clicked; the throw rejected the promise before reaching the WS connect, so the backend never got the start request — no worker spawned, logs empty, simulation "didn't start" with no visible error. Same root cause would also break the multi-file editor (createFile, createFileGroup) on the same LAN-HTTP self-host setup, just less observably. Add a single generateUUID() helper that: 1. Uses crypto.randomUUID() when available (secure context fast path). 2. Falls back to crypto.getRandomValues() — which IS available in non-secure contexts — to build a v4 UUID by hand. 3. Final fallback to Math.random() if even that is missing (defensive — Web Crypto getRandomValues has been universal for years). Replace all 6 crypto.randomUUID() call sites: - frontend/src/simulation/Esp32Bridge.ts (2 sites — getTabSessionId) - frontend/src/store/useEditorStore.ts (4 sites — file IDs) Reported by a self-hoster on OrangePi 5B accessing Velxio via LAN IP. DevTools console showed: TypeError: crypto.randomUUID is not a function at Ph (...) at wh.connect (...) at startBoard (...) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:41:30 +07:00
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)
`;
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));
}
}
`;
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';
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-';
feat(editor): view-mode toggle, agent-chat slot, toolbar polish Changes that ship to OSS — all benign for self-hosters, but most are extension points the velxio-prod overlay (and any private fork) needs to plug an in-editor AI chat into the page. Editor: - 3-way view-mode toggle (code / both / circuit) in the unified toolbar. Lets users hide a pane to give a right-docked sidebar (e.g. the AI chat overlay) more breathing room. Persisted in useEditorStore. - Default file explorer narrower (210 → 165 px); min 110. - Removed the redundant `tb-board-pill` (icon + "Editing: X" tooltip); the BoardSelector dropdown elsewhere already shows the active board. - Inlined Import/Export/Upload-firmware buttons; the 3-dot overflow menu gave up too much discoverability. Removed dead overflow state. Simulator: - Fix: global Delete/Backspace handler in SimulatorCanvas no longer fires when the event target is an INPUT/TEXTAREA/SELECT/contentEditable — affected any in-page text field, not just the chat overlay. Overlay extensibility: - New `data-velxio-slot="agent-chat"` at the bottom of EditorPage so pro overlays can portal a chat panel into the editor without forking the page. - vite.config.ts: preserveSymlinks=true when VITE_PRO_BUILD is set. Lets local-dev junctions (overlay tree → frontend/src/pro) resolve bare imports back to the OSS node_modules without resolving symlinks. Deps: - Added react-markdown + remark-gfm (rendered chat output) and @google/genai + zod (overlay agent loop). Tree-shaken from the OSS bundle when no pro code imports them. gitignore: - Ignore backend/app/pro/ and frontend/src/pro/ junctions used by developers running a private overlay against the OSS dev server. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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';
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;
feat(editor): view-mode toggle, agent-chat slot, toolbar polish Changes that ship to OSS — all benign for self-hosters, but most are extension points the velxio-prod overlay (and any private fork) needs to plug an in-editor AI chat into the page. Editor: - 3-way view-mode toggle (code / both / circuit) in the unified toolbar. Lets users hide a pane to give a right-docked sidebar (e.g. the AI chat overlay) more breathing room. Persisted in useEditorStore. - Default file explorer narrower (210 → 165 px); min 110. - Removed the redundant `tb-board-pill` (icon + "Editing: X" tooltip); the BoardSelector dropdown elsewhere already shows the active board. - Inlined Import/Export/Upload-firmware buttons; the 3-dot overflow menu gave up too much discoverability. Removed dead overflow state. Simulator: - Fix: global Delete/Backspace handler in SimulatorCanvas no longer fires when the event target is an INPUT/TEXTAREA/SELECT/contentEditable — affected any in-page text field, not just the chat overlay. Overlay extensibility: - New `data-velxio-slot="agent-chat"` at the bottom of EditorPage so pro overlays can portal a chat panel into the editor without forking the page. - vite.config.ts: preserveSymlinks=true when VITE_PRO_BUILD is set. Lets local-dev junctions (overlay tree → frontend/src/pro) resolve bare imports back to the OSS node_modules without resolving symlinks. Deps: - Added react-markdown + remark-gfm (rendered chat output) and @google/genai + zod (overlay agent loop). Tree-shaken from the OSS bundle when no pro code imports them. gitignore: - Ignore backend/app/pro/ and frontend/src/pro/ junctions used by developers running a private overlay against the OSS dev server. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:53:27 +07:00
viewMode: EditorViewMode;
setViewMode: (mode: EditorViewMode) => void;
// ── 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)
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;
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;
// 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<EditorState>((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,
feat(editor): view-mode toggle, agent-chat slot, toolbar polish Changes that ship to OSS — all benign for self-hosters, but most are extension points the velxio-prod overlay (and any private fork) needs to plug an in-editor AI chat into the page. Editor: - 3-way view-mode toggle (code / both / circuit) in the unified toolbar. Lets users hide a pane to give a right-docked sidebar (e.g. the AI chat overlay) more breathing room. Persisted in useEditorStore. - Default file explorer narrower (210 → 165 px); min 110. - Removed the redundant `tb-board-pill` (icon + "Editing: X" tooltip); the BoardSelector dropdown elsewhere already shows the active board. - Inlined Import/Export/Upload-firmware buttons; the 3-dot overflow menu gave up too much discoverability. Removed dead overflow state. Simulator: - Fix: global Delete/Backspace handler in SimulatorCanvas no longer fires when the event target is an INPUT/TEXTAREA/SELECT/contentEditable — affected any in-page text field, not just the chat overlay. Overlay extensibility: - New `data-velxio-slot="agent-chat"` at the bottom of EditorPage so pro overlays can portal a chat panel into the editor without forking the page. - vite.config.ts: preserveSymlinks=true when VITE_PRO_BUILD is set. Lets local-dev junctions (overlay tree → frontend/src/pro) resolve bare imports back to the OSS node_modules without resolving symlinks. Deps: - Added react-markdown + remark-gfm (rendered chat output) and @google/genai + zod (overlay agent loop). Tree-shaken from the OSS bundle when no pro code imports them. gitignore: - Ignore backend/app/pro/ and frontend/src/pro/ junctions used by developers running a private overlay against the OSS dev server. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:53:27 +07:00
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) => {
fix(frontend): polyfill crypto.randomUUID for non-secure contexts crypto.randomUUID() is only exposed on secure contexts (HTTPS, localhost, 127.0.0.1, ::1). When Velxio is self-hosted and accessed via a LAN IP over plain HTTP (e.g. http://192.168.31.139:3080/), crypto.randomUUID is undefined and any code path that calls it throws TypeError. This silently broke ESP32 simulation start for self-hosters: the frontend generates a UUID for the WS client_id when Run is clicked; the throw rejected the promise before reaching the WS connect, so the backend never got the start request — no worker spawned, logs empty, simulation "didn't start" with no visible error. Same root cause would also break the multi-file editor (createFile, createFileGroup) on the same LAN-HTTP self-host setup, just less observably. Add a single generateUUID() helper that: 1. Uses crypto.randomUUID() when available (secure context fast path). 2. Falls back to crypto.getRandomValues() — which IS available in non-secure contexts — to build a v4 UUID by hand. 3. Final fallback to Math.random() if even that is missing (defensive — Web Crypto getRandomValues has been universal for years). Replace all 6 crypto.randomUUID() call sites: - frontend/src/simulation/Esp32Bridge.ts (2 sites — getTabSessionId) - frontend/src/store/useEditorStore.ts (4 sites — file IDs) Reported by a self-hoster on OrangePi 5B accessing Velxio via LAN IP. DevTools console showed: TypeError: crypto.randomUUID is not a function at Ph (...) at wh.connect (...) at startBoard (...) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:41:30 +07:00
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) => ({
fix(frontend): polyfill crypto.randomUUID for non-secure contexts crypto.randomUUID() is only exposed on secure contexts (HTTPS, localhost, 127.0.0.1, ::1). When Velxio is self-hosted and accessed via a LAN IP over plain HTTP (e.g. http://192.168.31.139:3080/), crypto.randomUUID is undefined and any code path that calls it throws TypeError. This silently broke ESP32 simulation start for self-hosters: the frontend generates a UUID for the WS client_id when Run is clicked; the throw rejected the promise before reaching the WS connect, so the backend never got the start request — no worker spawned, logs empty, simulation "didn't start" with no visible error. Same root cause would also break the multi-file editor (createFile, createFileGroup) on the same LAN-HTTP self-host setup, just less observably. Add a single generateUUID() helper that: 1. Uses crypto.randomUUID() when available (secure context fast path). 2. Falls back to crypto.getRandomValues() — which IS available in non-secure contexts — to build a v4 UUID by hand. 3. Final fallback to Math.random() if even that is missing (defensive — Web Crypto getRandomValues has been universal for years). Replace all 6 crypto.randomUUID() call sites: - frontend/src/simulation/Esp32Bridge.ts (2 sites — getTabSessionId) - frontend/src/store/useEditorStore.ts (4 sites — file IDs) Reported by a self-hoster on OrangePi 5B accessing Velxio via LAN IP. DevTools console showed: TypeError: crypto.randomUUID is not a function at Ph (...) at wh.connect (...) at startBoard (...) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:41:30 +07:00
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) => ({
fix(frontend): polyfill crypto.randomUUID for non-secure contexts crypto.randomUUID() is only exposed on secure contexts (HTTPS, localhost, 127.0.0.1, ::1). When Velxio is self-hosted and accessed via a LAN IP over plain HTTP (e.g. http://192.168.31.139:3080/), crypto.randomUUID is undefined and any code path that calls it throws TypeError. This silently broke ESP32 simulation start for self-hosters: the frontend generates a UUID for the WS client_id when Run is clicked; the throw rejected the promise before reaching the WS connect, so the backend never got the start request — no worker spawned, logs empty, simulation "didn't start" with no visible error. Same root cause would also break the multi-file editor (createFile, createFileGroup) on the same LAN-HTTP self-host setup, just less observably. Add a single generateUUID() helper that: 1. Uses crypto.randomUUID() when available (secure context fast path). 2. Falls back to crypto.getRandomValues() — which IS available in non-secure contexts — to build a v4 UUID by hand. 3. Final fallback to Math.random() if even that is missing (defensive — Web Crypto getRandomValues has been universal for years). Replace all 6 crypto.randomUUID() call sites: - frontend/src/simulation/Esp32Bridge.ts (2 sites — getTabSessionId) - frontend/src/store/useEditorStore.ts (4 sites — file IDs) Reported by a self-hoster on OrangePi 5B accessing Velxio via LAN IP. DevTools console showed: TypeError: crypto.randomUUID is not a function at Ph (...) at wh.connect (...) at startBoard (...) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:41:30 +07:00
id: i === 0 ? `${groupId}-main` : generateUUID(),
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.
// 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>`; 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');
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) {
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 } };
});
},
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) => ({
fix(frontend): polyfill crypto.randomUUID for non-secure contexts crypto.randomUUID() is only exposed on secure contexts (HTTPS, localhost, 127.0.0.1, ::1). When Velxio is self-hosted and accessed via a LAN IP over plain HTTP (e.g. http://192.168.31.139:3080/), crypto.randomUUID is undefined and any code path that calls it throws TypeError. This silently broke ESP32 simulation start for self-hosters: the frontend generates a UUID for the WS client_id when Run is clicked; the throw rejected the promise before reaching the WS connect, so the backend never got the start request — no worker spawned, logs empty, simulation "didn't start" with no visible error. Same root cause would also break the multi-file editor (createFile, createFileGroup) on the same LAN-HTTP self-host setup, just less observably. Add a single generateUUID() helper that: 1. Uses crypto.randomUUID() when available (secure context fast path). 2. Falls back to crypto.getRandomValues() — which IS available in non-secure contexts — to build a v4 UUID by hand. 3. Final fallback to Math.random() if even that is missing (defensive — Web Crypto getRandomValues has been universal for years). Replace all 6 crypto.randomUUID() call sites: - frontend/src/simulation/Esp32Bridge.ts (2 sites — getTabSessionId) - frontend/src/store/useEditorStore.ts (4 sites — file IDs) Reported by a self-hoster on OrangePi 5B accessing Velxio via LAN IP. DevTools console showed: TypeError: crypto.randomUUID is not a function at Ph (...) at wh.connect (...) at startBoard (...) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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] ?? [],
};
});
},
// ── 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);
},
}));