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:
parent
5298c7e58c
commit
c9fe6f199d
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] ?? '',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue