feat(editor): folders - create/delete folder trees in the file explorer

File names may now carry '/' paths; the explorer renders each board group
as a collapsible folder tree (folders first, files in group order, CSS-var
indentation because .fe-file-item pins padding with !important). New-folder
buttons on the board header and per-folder hover actions (new file here,
new subfolder, delete with confirm when files are inside; a folder holding
every file of the group cannot be deleted - groups keep >= 1 file).

Model: names are the source of truth; only EMPTY folders are tracked
(folderGroups per group) so they survive until a file lands in them, then
materialise into the name prefix. Round-trips through .vlx (optional
folderGroups field, laxly validated), the auth stash draft, and
loadProjectState. Examples gain boards[].files so a single board can ship
a full multi-file tree (first entry = main sketch).

Backend: the arduino-cli sketch writer now creates subdirectories for
'/'-pathed names and REJECTS names that resolve outside the sketch dir
('..' traversal) - previously it crashed on the former and wrote outside
the tmp dir on the latter.
This commit is contained in:
David Montero Crespo 2026-08-05 06:48:38 +02:00
parent 5298c7e58c
commit c9fe6f199d
9 changed files with 464 additions and 18 deletions

View File

@ -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):

View File

@ -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;
}

View File

@ -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 = () => (
</svg>
);
const IcoFolder = ({ open }: { open: boolean }) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{open ? (
<path d="M6 14l1.5-5.5A2 2 0 0 1 9.44 7H20a2 2 0 0 1 1.94 2.5l-1.2 4.5A2 2 0 0 1 18.8 15.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2" />
) : (
<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>
);
const IcoNewFolder = () => (
<svg
width="22"
height="22"
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" />
<line x1="12" y1="10" x2="12" y2="16" />
<line x1="9" y1="13" x2="15" y2="13" />
</svg>
);
const IcoTrashSmall = () => (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
const IcoChevron = ({ open }: { open: boolean }) => (
<svg
width="12"
@ -234,6 +287,55 @@ function FileIcon({ name }: { name: string }) {
return <IcoFile />;
}
/**
* 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<string>();
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<FileExplorerProps> = ({ 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<FileExplorerProps> = ({ 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<string | null>(null);
const [creatingParentPath, setCreatingParentPath] = useState('');
const [newFileName, setNewFileName] = useState('');
// Inline new-folder input: which group + parent folder ('' = root).
const [creatingFolderInGroup, setCreatingFolderInGroup] = useState<string | null>(null);
const [creatingFolderParent, setCreatingFolderParent] = useState('');
const [newFolderName, setNewFolderName] = useState('');
// Collapsed state per board ID
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
// Collapsed folders, keyed `${groupId}:${folderPath}`
const [collapsedFolders, setCollapsedFolders] = useState<Record<string, boolean>>({});
const renameInputRef = useRef<HTMLInputElement>(null);
const sectionRenameInputRef = useRef<HTMLInputElement>(null);
@ -387,6 +500,13 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
// the input) discards instead of committing the typed value.
const sectionRenameCancelledRef = useRef(false);
const newFileInputRef = useRef<HTMLInputElement>(null);
const newFolderInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (creatingFolderInGroup && newFolderInputRef.current) {
newFolderInputRef.current.focus();
}
}, [creatingFolderInGroup]);
useEffect(() => {
if (renamingId && renameInputRef.current) {
@ -550,20 +670,67 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ 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<FileExplorerProps> = ({ onSaveClick, onNewCl
>
<IcoNewFile />
</button>
<button
className="fe-board-new-btn"
title={t('editor.fileExplorer.newFolderInBoard', 'New folder')}
onClick={(e) => {
e.stopPropagation();
startCreateFolder(board.id, groupId);
}}
>
<IcoNewFolder />
</button>
</div>
{/* Files under this board */}
{/* Files under this board, as a folder tree */}
{isOpen && (
<div className="fe-board-files">
{groupFiles.map((file) => {
{buildTreeRows(
groupFiles,
folderGroups[groupId] ?? [],
(p) => !!collapsedFolders[`${groupId}:${p}`],
).map((row) => {
if (row.kind === 'folder') {
return (
<div
key={`folder:${row.path}`}
className="file-explorer-item fe-file-item fe-folder-item"
style={{ '--fe-depth': row.depth } as React.CSSProperties}
onClick={() => toggleFolder(groupId, row.path)}
title={row.path}
>
<span className="fe-folder-chevron">
<IcoChevron open={row.open} />
</span>
<span className="file-explorer-icon fe-folder-icon">
<IcoFolder open={row.open} />
</span>
<span className="file-explorer-name">{row.name}</span>
<span className="fe-folder-actions">
<button
title={t('editor.fileExplorer.newFileInFolder', 'New file here')}
onClick={(e) => {
e.stopPropagation();
startCreateFile(board.id, groupId, row.path);
}}
>
<IcoNewFile />
</button>
<button
title={t('editor.fileExplorer.newSubfolder', 'New subfolder')}
onClick={(e) => {
e.stopPropagation();
startCreateFolder(board.id, groupId, row.path);
}}
>
<IcoNewFolder />
</button>
<button
className="fe-folder-delete"
title={t('editor.fileExplorer.deleteFolder', 'Delete folder')}
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(board.id, groupId, row.path);
}}
>
<IcoTrashSmall />
</button>
</span>
</div>
);
}
const file = row.file;
const isActiveFile = isActiveBoard && file.id === activeFileId;
return (
<div
key={file.id}
className={`file-explorer-item fe-file-item${isActiveFile ? ' file-explorer-item-active' : ''}`}
style={{ '--fe-depth': row.depth } as React.CSSProperties}
onClick={() => handleFileClick(file.id, board.id, groupId)}
onContextMenu={(e) => handleContextMenu(e, file.id, groupId)}
onDoubleClick={() => {
@ -742,7 +974,7 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="file-explorer-name">{file.name}</span>
<span className="file-explorer-name">{fileBasename(file.name)}</span>
)}
{file.modified && (
@ -754,7 +986,14 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
{/* Inline new-file input for this group */}
{creatingInGroup === groupId && (
<div className="file-explorer-item file-explorer-item-new fe-file-item">
<div
className="file-explorer-item file-explorer-item-new fe-file-item"
style={
creatingParentPath
? ({ '--fe-depth': creatingParentPath.split('/').length } as React.CSSProperties)
: undefined
}
>
<span className="file-explorer-icon">
<IcoFile />
</span>
@ -762,13 +1001,16 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ 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<FileExplorerProps> = ({ onSaveClick, onNewCl
</div>
)}
{/* Inline new-folder input for this group */}
{creatingFolderInGroup === groupId && (
<div
className="file-explorer-item file-explorer-item-new fe-file-item"
style={
creatingFolderParent
? ({ '--fe-depth': creatingFolderParent.split('/').length } as React.CSSProperties)
: undefined
}
>
<span className="file-explorer-icon fe-folder-icon">
<IcoFolder open={true} />
</span>
<input
ref={newFolderInputRef}
className="file-explorer-rename-input"
value={newFolderName}
placeholder={t('editor.fileExplorer.folderNamePlaceholder', 'folder name')}
onChange={(e) => setNewFolderName(e.target.value)}
onBlur={commitCreateFolder}
onKeyDown={(e) => {
if (e.key === 'Enter') commitCreateFolder();
if (e.key === 'Escape') {
setCreatingFolderInGroup(null);
setNewFolderName('');
}
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
{/* 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.

View File

@ -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<string, string>;
/** Uploads pre-loaded onto a board's BUILT-IN microSD slot (a ProBoardDef

View File

@ -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<string, string[]>;
// ── 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<string, string[]>;
/** 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<string, { name: string; content: string }[]>) => 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<string, { name: string; content: string }[]>,
folders?: Record<string, string[]>,
) => void;
// Settings
setTheme: (theme: 'vs-dark' | 'light') => void;
@ -202,6 +234,7 @@ export const useEditorStore = create<EditorState>((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<EditorState>((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<EditorState>((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<EditorState>((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<EditorState>((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<EditorState>((set, get) => ({
});
},
replaceFileGroups: (groups) => {
replaceFileGroups: (groups, folders) => {
const fileGroups: Record<string, WorkspaceFile[]> = {};
const activeGroupFileId: Record<string, string> = {};
const openGroupFileIds: Record<string, string[]> = {};
@ -494,6 +579,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
activeGroupFileId,
openGroupFileIds,
activeGroupId,
folderGroups: folders ?? {},
// Mirror legacy flat fields to the active group
files: groupFiles,
activeFileId: activeGroupFileId[activeGroupId] ?? '',

View File

@ -1002,6 +1002,7 @@ interface SimulatorState {
loadProjectState: (payload: {
boards: BoardInstance[];
fileGroups: Record<string, { name: string; content: string }[]>;
folderGroups?: Record<string, string[]>;
components: Component[];
wires: Wire[];
activeBoardId: string | null;
@ -1714,7 +1715,7 @@ export const useSimulatorStore = create<SimulatorState>((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)

View File

@ -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 <Arduino.h>. Only the Pi 3B
// uses a different toolchain (Python via VFS or g++ for `.cpp`).

View File

@ -56,6 +56,9 @@ export interface VlxPayload {
sdFiles?: Array<{ name: string; contentB64: string }>;
}>;
fileGroups: Record<string, Array<{ name: string; content: string }>>;
/** EMPTY folders per group (folders holding files exist via name prefixes).
* Optional — absent in projects saved before folders shipped. */
folderGroups?: Record<string, string[]>;
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<VlxPayload['folderGroups']> = {};
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<VlxPayload> {
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,

View File

@ -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,