diff --git a/backend/app/services/arduino_cli.py b/backend/app/services/arduino_cli.py index a4d86fa3..0655bdec 100644 --- a/backend/app/services/arduino_cli.py +++ b/backend/app/services/arduino_cli.py @@ -324,7 +324,16 @@ class ArduinoCLIService: if "rp2040" in board_fqbn and write_name == "sketch.ino": content = "#define Serial Serial1\n" + content - (sketch_dir / write_name).write_text(content, encoding="utf-8") + # Folder support: names may carry '/' paths ("apps/badge/x.py"). + # Resolve inside the sketch dir and REJECT anything that + # escapes it ('..' segments, absolute paths) — the name comes + # straight from the request body. + target = (sketch_dir / write_name).resolve() + if not str(target).startswith(str(sketch_dir.resolve()) + os.sep): + print(f"Skipping unsafe file name: {write_name!r}") + continue + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") # Fallback: no .ino files provided at all if not any(f["name"].endswith(".ino") for f in files): diff --git a/frontend/src/components/editor/FileExplorer.css b/frontend/src/components/editor/FileExplorer.css index 7d53ee89..659cf1f3 100644 --- a/frontend/src/components/editor/FileExplorer.css +++ b/frontend/src/components/editor/FileExplorer.css @@ -283,5 +283,57 @@ } .fe-file-item { - padding-left: 26px !important; + /* --fe-depth: nesting level inside the board's folder tree (0 = root). */ + padding-left: calc(26px + var(--fe-depth, 0) * 14px) !important; +} + +/* ── Folder rows ────────────────────────────────────────────────────────── */ + +.fe-folder-item { + user-select: none; +} + +.fe-folder-chevron { + display: inline-flex; + align-items: center; + margin-right: 2px; + color: #9d9d9d; +} + +.fe-folder-icon { + color: #d8a12a; +} + +.fe-folder-actions { + display: none; + margin-left: auto; + align-items: center; + gap: 2px; +} + +.fe-folder-item:hover .fe-folder-actions { + display: inline-flex; +} + +.fe-folder-actions button { + display: inline-flex; + align-items: center; + background: none; + border: none; + padding: 1px; + cursor: pointer; + color: #9d9d9d; +} + +.fe-folder-actions button:hover { + color: #fff; +} + +.fe-folder-actions button svg { + width: 14px; + height: 14px; +} + +.fe-folder-actions .fe-folder-delete:hover { + color: #f87171; } diff --git a/frontend/src/components/editor/FileExplorer.tsx b/frontend/src/components/editor/FileExplorer.tsx index 892e5a7f..15c4d363 100644 --- a/frontend/src/components/editor/FileExplorer.tsx +++ b/frontend/src/components/editor/FileExplorer.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore'; +import type { WorkspaceFile } from '../../store/useEditorStore'; import { useSimulatorStore } from '../../store/useSimulatorStore'; import { isProgrammableChip, @@ -130,6 +131,58 @@ const IcoOpen = () => ( ); +const IcoFolder = ({ open }: { open: boolean }) => ( + + {open ? ( + + ) : ( + + )} + +); + +const IcoNewFolder = () => ( + + + + + +); + +const IcoTrashSmall = () => ( + + + + +); + const IcoChevron = ({ open }: { open: boolean }) => ( ; } +/** + * One row of a board group's file tree, flattened for rendering. Folders come + * from file-name '/' prefixes plus the group's tracked EMPTY folders; a + * collapsed folder hides everything beneath it. + */ +type TreeRow = + | { kind: 'folder'; path: string; name: string; depth: number; open: boolean } + | { kind: 'file'; file: WorkspaceFile; depth: number }; + +function buildTreeRows( + files: WorkspaceFile[], + emptyFolders: string[], + isFolderCollapsed: (path: string) => boolean, +): TreeRow[] { + const folderSet = new Set(); + const addWithAncestors = (path: string) => { + const segs = path.split('/'); + for (let i = 1; i <= segs.length; i++) folderSet.add(segs.slice(0, i).join('/')); + }; + for (const f of files) { + const idx = f.name.lastIndexOf('/'); + if (idx > 0) addWithAncestors(f.name.slice(0, idx)); + } + for (const p of emptyFolders) if (p) addWithAncestors(p); + + const parentOf = (p: string) => { + const idx = p.lastIndexOf('/'); + return idx === -1 ? '' : p.slice(0, idx); + }; + const rows: TreeRow[] = []; + const walk = (parent: string, depth: number) => { + const childFolders = [...folderSet].filter((p) => parentOf(p) === parent).sort(); + for (const p of childFolders) { + const open = !isFolderCollapsed(p); + rows.push({ kind: 'folder', path: p, name: p.slice(p.lastIndexOf('/') + 1), depth, open }); + if (open) walk(p, depth + 1); + } + // Files keep their group order (the first file is the main sketch). + for (const f of files) { + if (parentOf(f.name) === parent) rows.push({ kind: 'file', file: f, depth }); + } + }; + walk('', 0); + return rows; +} + +/** Display name of a possibly-nested file: its basename. */ +const fileBasename = (name: string) => name.slice(name.lastIndexOf('/') + 1); + interface ContextMenu { fileId: string; boardGroupId: string; @@ -305,12 +407,15 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl const { fileGroups, + folderGroups, activeFileId, activeGroupId, openFile, createFile, deleteFile, renameFile, + createFolder, + deleteFolder, setActiveGroup, manifestViewBoardId, setManifestView, @@ -375,11 +480,19 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl kind: 'board' | 'chip'; } | null>(null); const [sectionRenameValue, setSectionRenameValue] = useState(''); - // Track which board group is creating a file: boardGroupId or null + // Track which board group is creating a file: boardGroupId or null. + // creatingParentPath prefixes the new file into a folder ('' = group root). const [creatingInGroup, setCreatingInGroup] = useState(null); + const [creatingParentPath, setCreatingParentPath] = useState(''); const [newFileName, setNewFileName] = useState(''); + // Inline new-folder input: which group + parent folder ('' = root). + const [creatingFolderInGroup, setCreatingFolderInGroup] = useState(null); + const [creatingFolderParent, setCreatingFolderParent] = useState(''); + const [newFolderName, setNewFolderName] = useState(''); // Collapsed state per board ID const [collapsed, setCollapsed] = useState>({}); + // Collapsed folders, keyed `${groupId}:${folderPath}` + const [collapsedFolders, setCollapsedFolders] = useState>({}); const renameInputRef = useRef(null); const sectionRenameInputRef = useRef(null); @@ -387,6 +500,13 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl // the input) discards instead of committing the typed value. const sectionRenameCancelledRef = useRef(false); const newFileInputRef = useRef(null); + const newFolderInputRef = useRef(null); + + useEffect(() => { + if (creatingFolderInGroup && newFolderInputRef.current) { + newFolderInputRef.current.focus(); + } + }, [creatingFolderInGroup]); useEffect(() => { if (renamingId && renameInputRef.current) { @@ -550,20 +670,67 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl // eslint-disable-next-line react-hooks/exhaustive-deps }, [handleOpenProjectClick]); - const startCreateFile = (boardId: string, groupId: string) => { + const startCreateFile = (boardId: string, groupId: string, parentPath = '') => { // Switch to this board first so createFile targets the right group switchToBoard(boardId, groupId); setCreatingInGroup(groupId); + setCreatingParentPath(parentPath); setNewFileName(''); + setCreatingFolderInGroup(null); setContextMenu(null); }; - const commitCreateFile = useCallback(() => { - const name = newFileName.trim(); - if (name) createFile(name); + const startCreateFolder = (boardId: string, groupId: string, parentPath = '') => { + switchToBoard(boardId, groupId); + setCreatingFolderInGroup(groupId); + setCreatingFolderParent(parentPath); + setNewFolderName(''); setCreatingInGroup(null); + setContextMenu(null); + }; + + const commitCreateFolder = useCallback(() => { + const name = newFolderName.trim().replace(/^\/+|\/+$/g, ''); + if (name) createFolder(creatingFolderParent ? `${creatingFolderParent}/${name}` : name); + setCreatingFolderInGroup(null); + setNewFolderName(''); + }, [newFolderName, creatingFolderParent, createFolder]); + + const toggleFolder = (groupId: string, path: string) => { + const key = `${groupId}:${path}`; + setCollapsedFolders((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const handleDeleteFolder = (boardId: string, groupId: string, path: string) => { + switchToBoard(boardId, groupId); + const inside = (fileGroups[groupId] ?? []).filter((f) => f.name.startsWith(path + '/')); + // Never allow a folder delete to empty the group completely — the group + // must keep at least one file (same invariant as single-file delete). + if (inside.length >= (fileGroups[groupId] ?? []).length) { + showMessageDialog(t('editor.fileExplorer.folderHoldsEverything', 'The folder holds every file of this board — a board needs at least one file.'), { kind: 'info' }); + return; + } + if (inside.length === 0) { + deleteFolder(path); + return; + } + void showConfirmDialog( + t( + 'editor.fileExplorer.confirmDeleteFolder', + `Delete the folder "${path}" and the ${inside.length} file(s) inside it?`, + ), + ).then((ok) => { + if (ok) deleteFolder(path); + }); + }; + + const commitCreateFile = useCallback(() => { + const name = newFileName.trim().replace(/^\/+/, ''); + if (name) createFile(creatingParentPath ? `${creatingParentPath}/${name}` : name); + setCreatingInGroup(null); + setCreatingParentPath(''); setNewFileName(''); - }, [newFileName, createFile]); + }, [newFileName, creatingParentPath, createFile]); const toggleCollapse = (boardId: string) => { setCollapsed((prev) => ({ ...prev, [boardId]: !prev[boardId] })); @@ -705,17 +872,82 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl > + - {/* Files under this board */} + {/* Files under this board, as a folder tree */} {isOpen && (
- {groupFiles.map((file) => { + {buildTreeRows( + groupFiles, + folderGroups[groupId] ?? [], + (p) => !!collapsedFolders[`${groupId}:${p}`], + ).map((row) => { + if (row.kind === 'folder') { + return ( +
toggleFolder(groupId, row.path)} + title={row.path} + > + + + + + + + {row.name} + + + + + +
+ ); + } + const file = row.file; const isActiveFile = isActiveBoard && file.id === activeFileId; return (
handleFileClick(file.id, board.id, groupId)} onContextMenu={(e) => handleContextMenu(e, file.id, groupId)} onDoubleClick={() => { @@ -742,7 +974,7 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl onClick={(e) => e.stopPropagation()} /> ) : ( - {file.name} + {fileBasename(file.name)} )} {file.modified && ( @@ -754,7 +986,14 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl {/* Inline new-file input for this group */} {creatingInGroup === groupId && ( -
+
@@ -762,13 +1001,16 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl ref={newFileInputRef} className="file-explorer-rename-input" value={newFileName} - placeholder="filename.ino" + placeholder={ + creatingParentPath ? `${creatingParentPath}/…` : 'filename.ino' + } onChange={(e) => setNewFileName(e.target.value)} onBlur={commitCreateFile} onKeyDown={(e) => { if (e.key === 'Enter') commitCreateFile(); if (e.key === 'Escape') { setCreatingInGroup(null); + setCreatingParentPath(''); setNewFileName(''); } }} @@ -777,6 +1019,38 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl
)} + {/* Inline new-folder input for this group */} + {creatingFolderInGroup === groupId && ( +
+ + + + setNewFolderName(e.target.value)} + onBlur={commitCreateFolder} + onKeyDown={(e) => { + if (e.key === 'Enter') commitCreateFolder(); + if (e.key === 'Escape') { + setCreatingFolderInGroup(null); + setNewFolderName(''); + } + }} + onClick={(e) => e.stopPropagation()} + /> +
+ )} + {/* velxio.json — THIS board's declared library manifest (compile scope), grouped with the board's code so it is clear which board it belongs to. There is one per board. diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index aa5d8aa5..f30c4960 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -30,6 +30,11 @@ export interface ExampleBoard { y: number; /** Arduino/firmware code loaded into this board's file group */ code?: string; + /** Full multi-file workspace for this board's file group. Names may carry + * '/' folder paths ("apps/badge/__init__.py") — the explorer renders them + * as a tree. Takes precedence over `code`; the FIRST entry becomes the + * active/main file, so lead with the sketch. */ + files?: Array<{ name: string; content: string }>; /** Files pre-loaded into the Pi VFS (path → content). Only used for raspberry-pi-3. */ vfsFiles?: Record; /** Uploads pre-loaded onto a board's BUILT-IN microSD slot (a ProBoardDef diff --git a/frontend/src/store/useEditorStore.ts b/frontend/src/store/useEditorStore.ts index 6a4a37fe..9013ca1b 100644 --- a/frontend/src/store/useEditorStore.ts +++ b/frontend/src/store/useEditorStore.ts @@ -11,6 +11,18 @@ export interface WorkspaceFile { const MAIN_ID = 'main-sketch'; +/** Trim, collapse slashes/backslashes, strip leading/trailing '/' and any + * '.'/'..' segments — folder paths are always workspace-relative. */ +export function normalizeFolderPath(path: string): string { + return path + .trim() + .replace(/\\/g, '/') + .split('/') + .map((seg) => seg.trim()) + .filter((seg) => seg && seg !== '.' && seg !== '..') + .join('/'); +} + const DEFAULT_INO_CONTENT = `// Arduino Blink Example void setup() { pinMode(LED_BUILTIN, OUTPUT); @@ -151,6 +163,21 @@ interface EditorState { /** Open file IDs within each group */ openGroupFileIds: Record; + // ── Folders ────────────────────────────────────────────────────────────── + /** + * Folders exist implicitly through file names containing '/' + * ("apps/badge/__init__.py" lives in apps/badge). This map only tracks + * EMPTY folders per group (created but not yet holding a file) so they + * survive in the explorer until a file lands in them. Paths are + * '/'-separated with no leading/trailing slash. + */ + folderGroups: Record; + /** Create a folder (in the active group). No-op if it already exists. */ + createFolder: (path: string) => void; + /** Delete a folder (active group): removes every file under it and any + * tracked empty subfolders. */ + deleteFolder: (path: string) => void; + // File operations (operate on active group) createFile: (name: string) => string; deleteFile: (id: string) => void; @@ -172,8 +199,13 @@ interface EditorState { setActiveGroup: (groupId: string) => void; getGroupFiles: (groupId: string) => WorkspaceFile[]; updateGroupFile: (groupId: string, fileId: string, content: string) => void; - /** Replace ALL file groups atomically (used when loading a saved project). */ - replaceFileGroups: (groups: Record) => void; + /** Replace ALL file groups atomically (used when loading a saved project). + * `folders` restores the tracked EMPTY folders per group (optional — + * folders holding files rebuild themselves from the file name prefixes). */ + replaceFileGroups: ( + groups: Record, + folders?: Record, + ) => void; // Settings setTheme: (theme: 'vs-dark' | 'light') => void; @@ -202,6 +234,7 @@ export const useEditorStore = create((set, get) => ({ fileGroups: { [DEFAULT_GROUP_ID]: [DEFAULT_FILE], }, + folderGroups: {}, activeGroupId: DEFAULT_GROUP_ID, activeGroupFileId: { [DEFAULT_GROUP_ID]: MAIN_ID }, openGroupFileIds: { [DEFAULT_GROUP_ID]: [MAIN_ID] }, @@ -209,6 +242,52 @@ export const useEditorStore = create((set, get) => ({ codeChangedSinceLastCompile: true, markCompiled: () => set({ codeChangedSinceLastCompile: false }), + // ── Folder operations (operate on active group) ───────────────────────── + + createFolder: (path: string) => { + const clean = normalizeFolderPath(path); + if (!clean) return; + set((s) => { + const groupId = s.activeGroupId; + const folders = s.folderGroups[groupId] ?? []; + const files = s.fileGroups[groupId] ?? []; + const exists = + folders.includes(clean) || files.some((f) => f.name.startsWith(clean + '/')); + if (exists) return {}; + return { folderGroups: { ...s.folderGroups, [groupId]: [...folders, clean] } }; + }); + }, + + deleteFolder: (path: string) => { + const clean = normalizeFolderPath(path); + if (!clean) return; + set((s) => { + const groupId = s.activeGroupId; + const prefix = clean + '/'; + const doomed = new Set( + (s.fileGroups[groupId] ?? []).filter((f) => f.name.startsWith(prefix)).map((f) => f.id), + ); + const files = s.files.filter((f) => !doomed.has(f.id)); + const openFileIds = s.openFileIds.filter((fid) => !doomed.has(fid)); + let activeFileId = s.activeFileId; + if (doomed.has(activeFileId)) activeFileId = openFileIds[0] ?? files[0]?.id ?? ''; + const groupFiles = (s.fileGroups[groupId] ?? []).filter((f) => !doomed.has(f.id)); + const groupOpenIds = (s.openGroupFileIds[groupId] ?? []).filter((fid) => !doomed.has(fid)); + const folders = (s.folderGroups[groupId] ?? []).filter( + (p) => p !== clean && !p.startsWith(prefix), + ); + return { + files, + openFileIds, + activeFileId, + fileGroups: { ...s.fileGroups, [groupId]: groupFiles }, + openGroupFileIds: { ...s.openGroupFileIds, [groupId]: groupOpenIds }, + activeGroupFileId: { ...s.activeGroupFileId, [groupId]: activeFileId }, + folderGroups: { ...s.folderGroups, [groupId]: folders }, + }; + }); + }, + // ── File operations (legacy API — operate on active group) ────────────── createFile: (name: string) => { @@ -217,6 +296,9 @@ export const useEditorStore = create((set, get) => ({ set((s) => { const groupId = s.activeGroupId; const groupFiles = [...(s.fileGroups[groupId] ?? []), newFile]; + // A file landing inside a tracked empty folder materialises it — the + // folder now exists through the file's path prefix. + const folders = (s.folderGroups[groupId] ?? []).filter((p) => !name.startsWith(p + '/')); return { // Legacy flat list (mirrors active group) files: [...s.files, newFile], @@ -229,6 +311,7 @@ export const useEditorStore = create((set, get) => ({ [groupId]: [...(s.openGroupFileIds[groupId] ?? []), id], }, activeGroupFileId: { ...s.activeGroupFileId, [groupId]: id }, + folderGroups: { ...s.folderGroups, [groupId]: folders }, }; }); return id; @@ -433,10 +516,12 @@ export const useEditorStore = create((set, get) => ({ const { [groupId]: _removed, ...rest } = s.fileGroups; const { [groupId]: _a, ...restActive } = s.activeGroupFileId; const { [groupId]: _o, ...restOpen } = s.openGroupFileIds; + const { [groupId]: _f, ...restFolders } = s.folderGroups; return { fileGroups: rest, activeGroupFileId: restActive, openGroupFileIds: restOpen, + folderGroups: restFolders, }; }); }, @@ -468,7 +553,7 @@ export const useEditorStore = create((set, get) => ({ }); }, - replaceFileGroups: (groups) => { + replaceFileGroups: (groups, folders) => { const fileGroups: Record = {}; const activeGroupFileId: Record = {}; const openGroupFileIds: Record = {}; @@ -494,6 +579,7 @@ export const useEditorStore = create((set, get) => ({ activeGroupFileId, openGroupFileIds, activeGroupId, + folderGroups: folders ?? {}, // Mirror legacy flat fields to the active group files: groupFiles, activeFileId: activeGroupFileId[activeGroupId] ?? '', diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 0996df49..1c1ceeb3 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -1002,6 +1002,7 @@ interface SimulatorState { loadProjectState: (payload: { boards: BoardInstance[]; fileGroups: Record; + folderGroups?: Record; components: Component[]; wires: Wire[]; activeBoardId: string | null; @@ -1714,7 +1715,7 @@ export const useSimulatorStore = create((set, get) => { // Replace editor file groups atomically. Skip groups that already exist // (createFileGroup is a no-op for existing ids) — overwrite their files. - useEditorStore.getState().replaceFileGroups(payload.fileGroups); + useEditorStore.getState().replaceFileGroups(payload.fileGroups, payload.folderGroups); // Components and wires. Normalize the retired ssd1306-i2c / ssd1306-spi // ids (merged into the single auto-detecting `ssd1306`, issues #101/#215) diff --git a/frontend/src/utils/loadExample.ts b/frontend/src/utils/loadExample.ts index 0b1420bf..e81eb209 100644 --- a/frontend/src/utils/loadExample.ts +++ b/frontend/src/utils/loadExample.ts @@ -166,7 +166,13 @@ export async function loadExample( const board = newBoards.find((b) => b.id === boardId); if (!board) return; - if (eb.code) { + if (eb.files?.length) { + // Full multi-file workspace (may carry '/' folder paths). First + // entry is the main/active file — the example must lead with the + // sketch so compile picks it up. + useEditorStore.getState().setActiveGroup(board.activeFileGroupId); + useEditorStore.getState().loadFiles(eb.files); + } else if (eb.code) { // Arduino-style boards (AVR, RP2040, ESP32, …) all need the `.ino` // extension so arduino-cli auto-includes . Only the Pi 3B // uses a different toolchain (Python via VFS or g++ for `.cpp`). diff --git a/frontend/src/utils/vlxFile.ts b/frontend/src/utils/vlxFile.ts index bc2112dc..b2888b8f 100644 --- a/frontend/src/utils/vlxFile.ts +++ b/frontend/src/utils/vlxFile.ts @@ -56,6 +56,9 @@ export interface VlxPayload { sdFiles?: Array<{ name: string; contentB64: string }>; }>; fileGroups: Record>; + /** EMPTY folders per group (folders holding files exist via name prefixes). + * Optional — absent in projects saved before folders shipped. */ + folderGroups?: Record; components: Component[]; wires: Wire[]; activeBoardId: string | null; @@ -95,11 +98,14 @@ export function buildVlxPayload(opts: { name?: string } = {}): VlxPayload { if (editor.fileGroups[gid]?.length) referencedGroupIds.add(gid); } const fileGroups: VlxPayload['fileGroups'] = {}; + const folderGroups: NonNullable = {}; for (const gid of referencedGroupIds) { fileGroups[gid] = (editor.fileGroups[gid] ?? []).map((f) => ({ name: f.name, content: f.content, })); + const folders = editor.folderGroups[gid] ?? []; + if (folders.length) folderGroups[gid] = folders; } return { @@ -109,6 +115,7 @@ export function buildVlxPayload(opts: { name?: string } = {}): VlxPayload { name: opts.name, boards: sim.boards.map(serialisableBoard), fileGroups, + ...(Object.keys(folderGroups).length ? { folderGroups } : {}), components: sim.components, wires: sim.wires, activeBoardId: sim.activeBoardId, @@ -196,6 +203,10 @@ function validatePayload(data: unknown): VlxPayload { if (!isPlainObject(data.fileGroups)) { throw new VlxParseError('Missing or invalid "fileGroups" object.'); } + if (data.folderGroups !== undefined && !isPlainObject(data.folderGroups)) { + // Optional field (empty folders per group) — drop silently if malformed. + delete data.folderGroups; + } if (!Array.isArray(data.components)) { throw new VlxParseError('Missing or invalid "components" array.'); } @@ -242,6 +253,7 @@ export async function importVlxFile(file: File): Promise { useSimulatorStore.getState().loadProjectState({ boards: payload.boards as unknown as BoardInstance[], fileGroups: payload.fileGroups, + folderGroups: payload.folderGroups, components: payload.components, wires: payload.wires, activeBoardId: payload.activeBoardId, diff --git a/frontend/src/utils/workspaceDraft.ts b/frontend/src/utils/workspaceDraft.ts index 6b4ce9f3..1acba668 100644 --- a/frontend/src/utils/workspaceDraft.ts +++ b/frontend/src/utils/workspaceDraft.ts @@ -65,6 +65,7 @@ export function restoreStashedWorkspace(): boolean { useSimulatorStore.getState().loadProjectState({ boards: payload.boards as unknown as BoardInstance[], fileGroups: payload.fileGroups, + folderGroups: payload.folderGroups, components: payload.components, wires: payload.wires, activeBoardId: payload.activeBoardId,