2026-03-04 08:25:52 +07:00
/ * *
2026-03-05 08:05:23 +07:00
* Editor Page — main editor + simulator with resizable panels
2026-03-04 08:25:52 +07:00
* /
2026-03-16 00:04:01 +07:00
import React , { useRef , useState , useCallback , useEffect , lazy , Suspense } from 'react' ;
feat: electrical simulation via ngspice-WASM (eecircuit-engine)
Adds full SPICE-accurate electrical simulation to Velxio, behind a lazy-
loaded ⚡ toolbar toggle. Arduino / ESP32 / RP2040 sketches now co-simulate
with real analog behaviour: correct voltages on wires, real I–V curves on
LEDs, working potentiometers, NTC thermistors read by analogRead(), PWM
driving RC filters, transistors, op-amps, diodes, MOSFETs, etc.
Engine: eecircuit-engine (ngspice compiled to WebAssembly). Main bundle
stays at 2.4 MB; the 20 MB SPICE chunk only loads when the user activates
electrical mode. Disabled at build time via VITE_ELECTRICAL_SIM=false.
Frontend additions:
- simulation/spice/: SpiceEngine wrapper + lazy entry, NetlistBuilder with
UnionFind over wires, componentToSpice mapping (24 metadataIds incl.
real part numbers: 2N2222, 2N3055, BC547, IRF540, 2N7000, 1N4148,
1N4007, 1N4733, LEDs, NTC, op-amp ideal), CircuitScheduler with
debounced coalescing, AVRSpiceBridge for quasi-static co-simulation.
- store/useElectricalStore: Zustand slice, feature-flag aware.
- components/analog-ui/: ⚡ toolbar toggle + SVG voltage overlay.
- components/components-instruments/: Voltmeter, Ammeter probes.
- 62 tests (spice-*, netlist-builder, component-to-spice, instruments).
Sandbox (test/test_circuit/): 47-test validation sandbox that proved
the approach (hand-rolled MNA baseline + ngspice pipeline) before
porting to the app. Kept as reference.
Docs: docs/wiki/circuit-emulation-*.md (13 engineering pages covering
architecture, solvers, components, AVR bridge, gotchas, performance,
integration plan, API reference, appendix) + electrical-simulation-
user-guide.md (end-user facing).
Reference plan: test/test_circuit/plan/phase_8_velxio_implementation.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:11:54 +07:00
import { wireElectricalSolver } from '../simulation/spice/subscribeToStore' ;
2026-03-23 23:32:15 +07:00
import { useSEO } from '../utils/useSEO' ;
2026-03-04 08:25:52 +07:00
import { CodeEditor } from '../components/editor/CodeEditor' ;
import { EditorToolbar } from '../components/editor/EditorToolbar' ;
2026-03-06 20:14:50 +07:00
import { FileTabs } from '../components/editor/FileTabs' ;
import { FileExplorer } from '../components/editor/FileExplorer' ;
2026-03-16 00:04:01 +07:00
// Lazy-load Pi workspace so xterm.js isn't in the main bundle
const RaspberryPiWorkspace = lazy ( ( ) = >
2026-04-22 02:45:45 +07:00
import ( '../components/raspberry-pi/RaspberryPiWorkspace' ) . then ( ( m ) = > ( {
default : m . RaspberryPiWorkspace ,
} ) ) ,
2026-03-16 00:04:01 +07:00
) ;
2026-03-06 07:07:10 +07:00
import { CompilationConsole } from '../components/editor/CompilationConsole' ;
2026-03-04 08:25:52 +07:00
import { SimulatorCanvas } from '../components/simulator/SimulatorCanvas' ;
2026-03-05 16:56:14 +07:00
import { SerialMonitor } from '../components/simulator/SerialMonitor' ;
2026-03-11 22:09:24 +07:00
import { Oscilloscope } from '../components/simulator/Oscilloscope' ;
2026-03-06 20:14:50 +07:00
import { AppHeader } from '../components/layout/AppHeader' ;
import { SaveProjectModal } from '../components/layout/SaveProjectModal' ;
import { LoginPromptModal } from '../components/layout/LoginPromptModal' ;
2026-03-24 00:02:35 +07:00
import { GitHubStarBanner } from '../components/layout/GitHubStarBanner' ;
2026-05-01 01:27:58 +07:00
import { useSimulatorStore , DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore' ;
2026-03-11 22:09:24 +07:00
import { useOscilloscopeStore } from '../store/useOscilloscopeStore' ;
2026-03-06 20:14:50 +07:00
import { useAuthStore } from '../store/useAuthStore' ;
2026-05-01 01:27:58 +07:00
import { useProjectStore } from '../store/useProjectStore' ;
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
import { useAutoSaveProject } from '../hooks/useAutoSaveProject' ;
2026-03-06 07:07:10 +07:00
import type { CompilationLog } from '../utils/compilationLogger' ;
2026-03-04 08:25:52 +07:00
import '../App.css' ;
2026-03-10 23:09:32 +07:00
const MOBILE_BREAKPOINT = 768 ;
2026-03-06 07:07:10 +07:00
const BOTTOM_PANEL_MIN = 80 ;
const BOTTOM_PANEL_MAX = 600 ;
const BOTTOM_PANEL_DEFAULT = 200 ;
2026-03-06 21:20:47 +07:00
const EXPLORER_MIN = 120 ;
const EXPLORER_MAX = 500 ;
const EXPLORER_DEFAULT = 210 ;
2026-03-06 07:07:10 +07:00
const resizeHandleStyle : React.CSSProperties = {
height : 5 ,
flexShrink : 0 ,
cursor : 'row-resize' ,
background : '#2a2d2e' ,
borderTop : '1px solid #3c3c3c' ,
borderBottom : '1px solid #3c3c3c' ,
} ;
2026-03-04 08:25:52 +07:00
export const EditorPage : React.FC = ( ) = > {
2026-03-23 23:32:15 +07:00
useSEO ( {
2026-03-23 23:38:56 +07:00
title : 'Multi-Board Simulator Editor — Arduino, ESP32, RP2040, RISC-V | Velxio' ,
2026-03-23 23:32:15 +07:00
description :
2026-03-23 23:38:56 +07:00
'Write, compile and simulate Arduino, ESP32, Raspberry Pi Pico, ESP32-C3, and Raspberry Pi 3 code in your browser. 19 boards, 5 CPU architectures, 48+ components. Free and open-source.' ,
2026-03-23 23:32:15 +07:00
url : 'https://velxio.dev/editor' ,
} ) ;
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
// Silent auto-save for the loaded project (only fires when authed AND
// currentProject has a UUID — see useAutoSaveProject for the gating rules).
const autoSave = useAutoSaveProject ( ) ;
2026-03-05 08:05:23 +07:00
const [ editorWidthPct , setEditorWidthPct ] = useState ( 45 ) ;
const containerRef = useRef < HTMLDivElement > ( null ) ;
const resizingRef = useRef ( false ) ;
2026-03-05 16:56:14 +07:00
const serialMonitorOpen = useSimulatorStore ( ( s ) = > s . serialMonitorOpen ) ;
2026-03-16 00:04:01 +07:00
const activeBoardId = useSimulatorStore ( ( s ) = > s . activeBoardId ) ;
2026-04-22 02:45:45 +07:00
const activeBoardKind = useSimulatorStore (
( s ) = > s . boards . find ( ( b ) = > b . id === s . activeBoardId ) ? . boardKind ,
2026-03-16 00:04:01 +07:00
) ;
const isRaspberryPi3 = activeBoardKind === 'raspberry-pi-3' ;
2026-03-11 22:09:24 +07:00
const oscilloscopeOpen = useOscilloscopeStore ( ( s ) = > s . open ) ;
2026-03-06 07:07:10 +07:00
const [ consoleOpen , setConsoleOpen ] = useState ( false ) ;
const [ compileLogs , setCompileLogs ] = useState < CompilationLog [ ] > ( [ ] ) ;
const [ bottomPanelHeight , setBottomPanelHeight ] = useState ( BOTTOM_PANEL_DEFAULT ) ;
2026-03-06 20:14:50 +07:00
const [ saveModalOpen , setSaveModalOpen ] = useState ( false ) ;
const [ loginPromptOpen , setLoginPromptOpen ] = useState ( false ) ;
2026-03-24 00:02:35 +07:00
const [ showStarBanner , setShowStarBanner ] = useState ( false ) ;
feat: electrical simulation via ngspice-WASM (eecircuit-engine)
Adds full SPICE-accurate electrical simulation to Velxio, behind a lazy-
loaded ⚡ toolbar toggle. Arduino / ESP32 / RP2040 sketches now co-simulate
with real analog behaviour: correct voltages on wires, real I–V curves on
LEDs, working potentiometers, NTC thermistors read by analogRead(), PWM
driving RC filters, transistors, op-amps, diodes, MOSFETs, etc.
Engine: eecircuit-engine (ngspice compiled to WebAssembly). Main bundle
stays at 2.4 MB; the 20 MB SPICE chunk only loads when the user activates
electrical mode. Disabled at build time via VITE_ELECTRICAL_SIM=false.
Frontend additions:
- simulation/spice/: SpiceEngine wrapper + lazy entry, NetlistBuilder with
UnionFind over wires, componentToSpice mapping (24 metadataIds incl.
real part numbers: 2N2222, 2N3055, BC547, IRF540, 2N7000, 1N4148,
1N4007, 1N4733, LEDs, NTC, op-amp ideal), CircuitScheduler with
debounced coalescing, AVRSpiceBridge for quasi-static co-simulation.
- store/useElectricalStore: Zustand slice, feature-flag aware.
- components/analog-ui/: ⚡ toolbar toggle + SVG voltage overlay.
- components/components-instruments/: Voltmeter, Ammeter probes.
- 62 tests (spice-*, netlist-builder, component-to-spice, instruments).
Sandbox (test/test_circuit/): 47-test validation sandbox that proved
the approach (hand-rolled MNA baseline + ngspice pipeline) before
porting to the app. Kept as reference.
Docs: docs/wiki/circuit-emulation-*.md (13 engineering pages covering
architecture, solvers, components, AVR bridge, gotchas, performance,
integration plan, API reference, appendix) + electrical-simulation-
user-guide.md (end-user facing).
Reference plan: test/test_circuit/plan/phase_8_velxio_implementation.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:11:54 +07:00
// ── Electrical simulation subscriber (one-time, idempotent) ───────────────
useEffect ( ( ) = > {
const unsub = wireElectricalSolver ( ) ;
return unsub ;
} , [ ] ) ;
2026-03-24 00:02:35 +07:00
// ── GitHub star prompt (show once: 2nd visit OR after 3 min) ──────────────
useEffect ( ( ) = > {
const STAR_KEY = 'velxio_star_prompted' ;
const VISITS_KEY = 'velxio_editor_visits' ;
const FIRST_VISIT_KEY = 'velxio_editor_first_visit' ;
const THREE_MIN = 3 * 60 * 1000 ;
if ( localStorage . getItem ( STAR_KEY ) ) return ;
// Increment visit counter
const visits = parseInt ( localStorage . getItem ( VISITS_KEY ) ? ? '0' , 10 ) + 1 ;
localStorage . setItem ( VISITS_KEY , String ( visits ) ) ;
// Record timestamp of first visit
if ( ! localStorage . getItem ( FIRST_VISIT_KEY ) ) {
localStorage . setItem ( FIRST_VISIT_KEY , String ( Date . now ( ) ) ) ;
}
const firstVisit = parseInt ( localStorage . getItem ( FIRST_VISIT_KEY ) ! , 10 ) ;
// Show immediately on second+ visit
if ( visits >= 2 ) {
setShowStarBanner ( true ) ;
return ;
}
// Otherwise schedule after the 3-minute mark
const elapsed = Date . now ( ) - firstVisit ;
const delay = Math . max ( 0 , THREE_MIN - elapsed ) ;
const timer = setTimeout ( ( ) = > {
if ( ! localStorage . getItem ( STAR_KEY ) ) setShowStarBanner ( true ) ;
} , delay ) ;
return ( ) = > clearTimeout ( timer ) ;
} , [ ] ) ;
const handleDismissStarBanner = ( ) = > {
localStorage . setItem ( 'velxio_star_prompted' , '1' ) ;
setShowStarBanner ( false ) ;
} ;
2026-03-06 20:14:50 +07:00
const [ explorerOpen , setExplorerOpen ] = useState ( true ) ;
2026-03-06 21:20:47 +07:00
const [ explorerWidth , setExplorerWidth ] = useState ( EXPLORER_DEFAULT ) ;
2026-04-22 02:45:45 +07:00
const [ isMobile , setIsMobile ] = useState (
( ) = > window . matchMedia ( ` (max-width: ${ MOBILE_BREAKPOINT } px) ` ) . matches ,
) ;
2026-04-29 10:21:26 +07:00
// Slot element for SimulatorCanvas to portal its header into. When set, the
// canvas board selector / Serial / Scope / zoom / Add buttons render here
// instead of above the canvas — keeping the top bar a single full-width row
// that doesn't reflow when the editor/canvas splitter is dragged.
const [ canvasHeaderSlot , setCanvasHeaderSlot ] = useState < HTMLDivElement | null > ( null ) ;
2026-03-24 03:15:29 +07:00
// Default to 'code' on mobile — show the editor so users can write/view code
const [ mobileView , setMobileView ] = useState < 'code' | 'circuit' > ( 'code' ) ;
2026-03-06 20:14:50 +07:00
const user = useAuthStore ( ( s ) = > s . user ) ;
const handleSaveClick = useCallback ( ( ) = > {
if ( ! user ) {
setLoginPromptOpen ( true ) ;
} else {
setSaveModalOpen ( true ) ;
}
} , [ user ] ) ;
2026-05-01 01:27:58 +07:00
const handleNewClick = useCallback ( ( ) = > {
if (
! window . confirm (
'Start a new workspace? This clears every board, component, wire and file. This cannot be undone.' ,
)
) {
return ;
}
const sim = useSimulatorStore . getState ( ) ;
sim . boards . forEach ( ( b ) = > sim . stopBoard ( b . id ) ) ;
const ids = sim . boards . map ( ( b ) = > b . id ) ;
ids . forEach ( ( id ) = > sim . removeBoard ( id ) ) ;
sim . setComponents ( [ ] ) ;
sim . setWires ( [ ] ) ;
useProjectStore . getState ( ) . clearCurrentProject ( ) ;
const newId = useSimulatorStore
. getState ( )
. addBoard ( 'arduino-uno' , DEFAULT_BOARD_POSITION . x , DEFAULT_BOARD_POSITION . y ) ;
useSimulatorStore . getState ( ) . setActiveBoardId ( newId ) ;
} , [ ] ) ;
2026-03-10 23:09:32 +07:00
// Track mobile breakpoint
useEffect ( ( ) = > {
const mq = window . matchMedia ( ` (max-width: ${ MOBILE_BREAKPOINT } px) ` ) ;
const update = ( e : MediaQueryListEvent | MediaQueryList ) = > {
const mobile = e . matches ;
setIsMobile ( mobile ) ;
if ( mobile ) setExplorerOpen ( false ) ;
} ;
update ( mq ) ;
mq . addEventListener ( 'change' , update ) ;
return ( ) = > mq . removeEventListener ( 'change' , update ) ;
} , [ ] ) ;
2026-03-06 20:14:50 +07:00
// Ctrl+S shortcut
useEffect ( ( ) = > {
const handler = ( e : KeyboardEvent ) = > {
if ( ( e . ctrlKey || e . metaKey ) && e . key === 's' ) {
e . preventDefault ( ) ;
handleSaveClick ( ) ;
}
} ;
window . addEventListener ( 'keydown' , handler ) ;
return ( ) = > window . removeEventListener ( 'keydown' , handler ) ;
} , [ handleSaveClick ] ) ;
2026-03-05 08:05:23 +07:00
2026-03-08 03:54:51 +07:00
// Prevent body scroll on the editor page
useEffect ( ( ) = > {
const html = document . documentElement ;
const body = document . body ;
html . style . overflow = 'hidden' ;
body . style . overflow = 'hidden' ;
window . scrollTo ( 0 , 0 ) ;
return ( ) = > {
html . style . overflow = '' ;
body . style . overflow = '' ;
} ;
} , [ ] ) ;
2026-03-05 08:05:23 +07:00
const handleResizeMouseDown = useCallback ( ( e : React.MouseEvent ) = > {
e . preventDefault ( ) ;
resizingRef . current = true ;
const handleMouseMove = ( ev : MouseEvent ) = > {
if ( ! resizingRef . current || ! containerRef . current ) return ;
const rect = containerRef . current . getBoundingClientRect ( ) ;
const pct = ( ( ev . clientX - rect . left ) / rect . width ) * 100 ;
setEditorWidthPct ( Math . max ( 20 , Math . min ( 80 , pct ) ) ) ;
} ;
const handleMouseUp = ( ) = > {
resizingRef . current = false ;
document . body . style . cursor = '' ;
document . body . style . userSelect = '' ;
document . removeEventListener ( 'mousemove' , handleMouseMove ) ;
document . removeEventListener ( 'mouseup' , handleMouseUp ) ;
} ;
document . body . style . cursor = 'col-resize' ;
document . body . style . userSelect = 'none' ;
document . addEventListener ( 'mousemove' , handleMouseMove ) ;
document . addEventListener ( 'mouseup' , handleMouseUp ) ;
} , [ ] ) ;
2026-04-22 02:45:45 +07:00
const handleBottomPanelResizeMouseDown = useCallback (
( e : React.MouseEvent ) = > {
e . preventDefault ( ) ;
const startY = e . clientY ;
const startHeight = bottomPanelHeight ;
const onMove = ( ev : MouseEvent ) = > {
const delta = startY - ev . clientY ;
setBottomPanelHeight (
Math . max ( BOTTOM_PANEL_MIN , Math . min ( BOTTOM_PANEL_MAX , startHeight + delta ) ) ,
) ;
} ;
const onUp = ( ) = > {
document . body . style . cursor = '' ;
document . body . style . userSelect = '' ;
document . removeEventListener ( 'mousemove' , onMove ) ;
document . removeEventListener ( 'mouseup' , onUp ) ;
} ;
document . body . style . cursor = 'row-resize' ;
document . body . style . userSelect = 'none' ;
document . addEventListener ( 'mousemove' , onMove ) ;
document . addEventListener ( 'mouseup' , onUp ) ;
} ,
[ bottomPanelHeight ] ,
) ;
2026-03-06 21:20:47 +07:00
2026-04-22 02:45:45 +07:00
const handleExplorerResizeMouseDown = useCallback (
( e : React.MouseEvent ) = > {
e . preventDefault ( ) ;
const startX = e . clientX ;
const startWidth = explorerWidth ;
const onMove = ( ev : MouseEvent ) = > {
const delta = ev . clientX - startX ;
setExplorerWidth ( Math . max ( EXPLORER_MIN , Math . min ( EXPLORER_MAX , startWidth + delta ) ) ) ;
} ;
const onUp = ( ) = > {
document . body . style . cursor = '' ;
document . body . style . userSelect = '' ;
document . removeEventListener ( 'mousemove' , onMove ) ;
document . removeEventListener ( 'mouseup' , onUp ) ;
} ;
document . body . style . cursor = 'col-resize' ;
document . body . style . userSelect = 'none' ;
document . addEventListener ( 'mousemove' , onMove ) ;
document . addEventListener ( 'mouseup' , onUp ) ;
} ,
[ explorerWidth ] ,
) ;
2026-03-06 21:20:47 +07:00
2026-03-04 08:25:52 +07:00
return (
< div className = "app" >
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
< AppHeader autoSave = { autoSave } / >
2026-03-05 08:05:23 +07:00
2026-03-24 03:15:29 +07:00
{ /* ── Mobile tab bar (top, above panels) ── */ }
{ isMobile && (
< nav className = "mobile-tab-bar" >
< button
className = { ` mobile-tab-btn ${ mobileView === 'code' ? ' mobile-tab-btn--active' : '' } ` }
onClick = { ( ) = > setMobileView ( 'code' ) }
>
2026-04-22 02:45:45 +07:00
< svg
width = "16"
height = "16"
viewBox = "0 0 24 24"
fill = "none"
stroke = "currentColor"
strokeWidth = "2"
strokeLinecap = "round"
strokeLinejoin = "round"
>
2026-03-24 03:15:29 +07:00
< polyline points = "16 18 22 12 16 6" / >
< polyline points = "8 6 2 12 8 18" / >
< / svg >
< span > & lt ; / & g t ; C o d e < / s p a n >
< / button >
< button
className = { ` mobile-tab-btn ${ mobileView === 'circuit' ? ' mobile-tab-btn--active' : '' } ` }
onClick = { ( ) = > setMobileView ( 'circuit' ) }
>
2026-04-22 02:45:45 +07:00
< svg
width = "16"
height = "16"
viewBox = "0 0 24 24"
fill = "none"
stroke = "currentColor"
strokeWidth = "2"
strokeLinecap = "round"
strokeLinejoin = "round"
>
2026-03-24 03:15:29 +07:00
< rect x = "2" y = "7" width = "20" height = "14" rx = "2" / >
< path d = "M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" / >
< line x1 = "12" y1 = "12" x2 = "12" y2 = "16" / >
< line x1 = "10" y1 = "14" x2 = "14" y2 = "14" / >
< / svg >
< span > Circuit < / span >
< / button >
< / nav >
) }
2026-04-29 10:21:26 +07:00
{ / * ─ ─ U n i f i e d t o p t o o l b a r ( d e s k t o p o n l y ) ─ ─
Editor controls + canvas controls share a single full - width row so
the bar doesn ' t reflow when the editor / canvas splitter is dragged .
The canvas controls ( board selector , Serial , Scope , zoom , Add ) are
portaled into ` canvasHeaderSlot ` from inside SimulatorCanvas . * / }
{ ! isMobile && (
< div className = "unified-toolbar" >
< button
className = "explorer-toggle-btn unified-toolbar-explorer-toggle"
onClick = { ( ) = > setExplorerOpen ( ( v ) = > ! v ) }
title = { explorerOpen ? 'Hide file explorer' : 'Show file explorer' }
>
< svg
width = "16"
height = "16"
viewBox = "0 0 24 24"
fill = "none"
stroke = "currentColor"
strokeWidth = "2"
strokeLinecap = "round"
strokeLinejoin = "round"
>
< path d = "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" / >
< / svg >
< / button >
< div className = "unified-toolbar-editor" >
< EditorToolbar
consoleOpen = { consoleOpen }
setConsoleOpen = { setConsoleOpen }
compileLogs = { compileLogs }
setCompileLogs = { setCompileLogs }
centerSlot = { ! isRaspberryPi3 ? < FileTabs / > : null }
/ >
< / div >
< div className = "unified-toolbar-canvas" ref = { setCanvasHeaderSlot } / >
< / div >
) }
2026-03-05 08:05:23 +07:00
< div className = "app-container" ref = { containerRef } >
2026-03-06 20:14:50 +07:00
{ /* ── Editor side ── */ }
< div
className = "editor-panel"
2026-03-10 23:09:32 +07:00
style = { {
width : isMobile ? '100%' : ` ${ editorWidthPct } % ` ,
display : isMobile && mobileView !== 'code' ? 'none' : 'flex' ,
flexDirection : 'row' ,
} }
2026-03-06 20:14:50 +07:00
>
2026-03-06 21:20:47 +07:00
{ /* File explorer sidebar + resize handle */ }
{ explorerOpen && (
< >
2026-04-22 02:45:45 +07:00
< div
style = { { width : explorerWidth , flexShrink : 0 , display : 'flex' , overflow : 'hidden' } }
>
2026-05-01 01:27:58 +07:00
< FileExplorer onSaveClick = { handleSaveClick } onNewClick = { handleNewClick } / >
2026-03-06 21:20:47 +07:00
< / div >
2026-03-10 23:09:32 +07:00
{ ! isMobile && (
2026-04-22 02:45:45 +07:00
< div
className = "explorer-resize-handle"
onMouseDown = { handleExplorerResizeMouseDown }
/ >
2026-03-10 23:09:32 +07:00
) }
2026-03-06 21:20:47 +07:00
< / >
) }
2026-03-06 20:14:50 +07:00
{ /* Editor main area */ }
2026-04-22 02:45:45 +07:00
< div
style = { {
flex : 1 ,
display : 'flex' ,
flexDirection : 'column' ,
overflow : 'hidden' ,
minWidth : 0 ,
} }
>
2026-04-29 10:21:26 +07:00
{ / * M o b i l e - o n l y : e x p l o r e r t o g g l e + e d i t o r t o o l b a r i n s i d e t h e p a n e l .
On desktop these are hoisted into the unified top toolbar . * / }
{ isMobile && (
< div style = { { display : 'flex' , alignItems : 'stretch' , flexShrink : 0 } } >
< button
className = "explorer-toggle-btn"
onClick = { ( ) = > setExplorerOpen ( ( v ) = > ! v ) }
title = { explorerOpen ? 'Hide file explorer' : 'Show file explorer' }
2026-04-22 02:45:45 +07:00
>
2026-04-29 10:21:26 +07:00
< svg
width = "16"
height = "16"
viewBox = "0 0 24 24"
fill = "none"
stroke = "currentColor"
strokeWidth = "2"
strokeLinecap = "round"
strokeLinejoin = "round"
>
< path d = "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" / >
< / svg >
< / button >
< div style = { { flex : 1 , minWidth : 0 } } >
< EditorToolbar
consoleOpen = { consoleOpen }
setConsoleOpen = { setConsoleOpen }
compileLogs = { compileLogs }
setCompileLogs = { setCompileLogs }
centerSlot = { ! isRaspberryPi3 ? < FileTabs / > : null }
/ >
< / div >
2026-03-06 07:07:10 +07:00
< / div >
2026-04-29 10:21:26 +07:00
) }
2026-03-06 20:14:50 +07:00
2026-03-16 00:04:01 +07:00
{ /* Editor area: Pi workspace or Monaco editor */ }
2026-03-06 20:14:50 +07:00
< div className = "editor-wrapper" style = { { flex : 1 , overflow : 'hidden' , minHeight : 0 } } >
2026-03-16 00:04:01 +07:00
{ isRaspberryPi3 && activeBoardId ? (
2026-04-22 02:45:45 +07:00
< Suspense
fallback = {
< div style = { { color : '#666' , padding : 16 , fontSize : 12 } } >
Loading Pi workspace …
< / div >
}
>
2026-03-16 00:04:01 +07:00
< RaspberryPiWorkspace boardId = { activeBoardId } / >
< / Suspense >
) : (
< CodeEditor / >
) }
2026-03-06 20:14:50 +07:00
< / div >
{ /* Console */ }
{ consoleOpen && (
< >
< div
onMouseDown = { handleBottomPanelResizeMouseDown }
style = { resizeHandleStyle }
title = "Drag to resize"
/ >
< div style = { { height : bottomPanelHeight , flexShrink : 0 } } >
< CompilationConsole
isOpen = { consoleOpen }
onClose = { ( ) = > setConsoleOpen ( false ) }
logs = { compileLogs }
onClear = { ( ) = > setCompileLogs ( [ ] ) }
/ >
< / div >
< / >
) }
< / div >
2026-03-04 08:25:52 +07:00
< / div >
2026-03-05 08:05:23 +07:00
2026-03-10 23:09:32 +07:00
{ /* Resize handle (desktop only) */ }
{ ! isMobile && (
< div className = "resize-handle" onMouseDown = { handleResizeMouseDown } >
< div className = "resize-handle-grip" / >
< / div >
) }
2026-03-05 08:05:23 +07:00
2026-03-06 20:14:50 +07:00
{ /* ── Simulator side ── */ }
< div
className = "simulator-panel"
2026-03-10 23:09:32 +07:00
style = { {
width : isMobile ? '100%' : ` ${ 100 - editorWidthPct } % ` ,
display : isMobile && mobileView !== 'circuit' ? 'none' : 'flex' ,
flexDirection : 'column' ,
} }
2026-03-06 20:14:50 +07:00
>
2026-03-06 07:07:10 +07:00
< div style = { { flex : 1 , overflow : 'hidden' , position : 'relative' , minHeight : 0 } } >
2026-04-29 10:21:26 +07:00
< SimulatorCanvas headerSlot = { ! isMobile ? canvasHeaderSlot : null } / >
2026-03-05 16:56:14 +07:00
< / div >
{ serialMonitorOpen && (
2026-03-06 07:07:10 +07:00
< >
< div
onMouseDown = { handleBottomPanelResizeMouseDown }
style = { resizeHandleStyle }
title = "Drag to resize"
/ >
< div style = { { height : bottomPanelHeight , flexShrink : 0 } } >
< SerialMonitor / >
< / div >
< / >
2026-03-05 16:56:14 +07:00
) }
2026-03-11 22:09:24 +07:00
{ oscilloscopeOpen && (
< >
< div
onMouseDown = { handleBottomPanelResizeMouseDown }
style = { resizeHandleStyle }
title = "Drag to resize"
/ >
< div style = { { height : bottomPanelHeight , flexShrink : 0 } } >
< Oscilloscope / >
< / div >
< / >
) }
2026-03-04 08:25:52 +07:00
< / div >
< / div >
2026-03-06 20:14:50 +07:00
{ saveModalOpen && < SaveProjectModal onClose = { ( ) = > setSaveModalOpen ( false ) } / > }
{ loginPromptOpen && < LoginPromptModal onClose = { ( ) = > setLoginPromptOpen ( false ) } / > }
2026-03-24 00:02:35 +07:00
{ showStarBanner && < GitHubStarBanner onClose = { handleDismissStarBanner } / > }
2026-03-04 08:25:52 +07:00
< / div >
) ;
} ;