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