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-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)
|
|
|
|
|
`;
|
|
|
|
|
|
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';
|
|
|
|
|
|
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[];
|
|
|
|
|
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-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,
|
|
|
|
|
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,
|
|
|
|
|
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-03-30 07:27:41 +07:00
|
|
|
// Determine default file by group name convention or language mode
|
2026-03-13 09:39:04 +07:00
|
|
|
const isPi = groupId.includes('raspberry-pi-3');
|
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');
|
|
|
|
|
if (isMicroPython && isEsp32) {
|
|
|
|
|
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
|
|
|
}));
|