feat(editor): rename boards & custom chips; show which target owns each file

Phase 2 of the run-system/UX work.

- BoardInstance gains an optional user ; boardDisplayName(board) resolver
  (name || kind label) routes every INSTANCE-label surface: file-explorer
  section header, compile console (EditorToolbar), canvas selector/tooltip/
  context-menu, Serial Monitor tabs, Oscilloscope board picker, Board Options
  subtitle. Board/component pickers keep the KIND label (they pick new boards).
- Inline rename on board AND chip section headers (double-click the name, or a
  hover pencil button). Board -> updateBoard(id,{name}); chip -> chipName in
  properties. Enter commits, Escape cancels (cancel-flag ref guards the
  unmount-fires-onBlur footgun), empty clears to the kind / 'Custom Chip'.
- FileTabs shows an owner badge naming the board/chip whose files are shown
  (resolved as a selector so it doesn't re-render on every sim pin toggle).
- CustomChipDialog no longer clobbers a user-given chipName: chip.json's name
  only seeds the blank defaults (My Chip / Custom Chip); loading an example
  relabels explicitly.
- Persistence: board name round-trips via projectPayload (+ dirty hash),
  vlxFile, ProjectByIdPage load + loadProjectState; chipName rides components_json.
- Drive-by: fixed a pre-existing rules-of-hooks violation in BoardOptionsModal
  (early return before a useCallback).

Reviewed by a 3-agent adversarial pass (completeness / persistence / correctness);
all major findings folded in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-06-04 06:22:25 +02:00
parent aa4c8123f2
commit a68e7f8e94
15 changed files with 280 additions and 41 deletions

View File

@ -64,6 +64,11 @@ function parseAttributes(chipJson: string): AttributeDef[] {
type Tab = 'examples' | 'editor';
// Placeholder chip names that are treated as "not user-named" — chip.json's
// `name` may seed over any of these. 'My Chip' is the dialog default; 'Custom
// Chip' is the file-explorer empty-rename fallback. Keep both in sync here.
const BLANK_CHIP_NAMES = new Set(['', 'My Chip', 'Custom Chip']);
export const CustomChipDialog = ({ initial, onClose, onSave }: CustomChipDialogProps) => {
const { t } = useTranslation();
// If the chip already has source code, skip the examples tab on open.
@ -105,20 +110,32 @@ export const CustomChipDialog = ({ initial, onClose, onSave }: CustomChipDialogP
return Array.from(m.entries());
}, []);
// Pull chipName out of chip.json whenever it changes — keeps a single source
// of truth without forcing the user to edit two fields.
// Seed chipName from chip.json's `name` ONLY while the chip still has a
// blank default name — so editing chip.json names a fresh chip, but a name
// the user gave it (renamed in the file explorer) is NOT clobbered. The
// display name lives in properties.chipName and wins; chip.json `name` is
// just the initial seed. (Switching examples relabels explicitly in
// loadExample.) Both blank defaults are recognised so neither sticks.
useEffect(() => {
try {
const obj = JSON.parse(chipJson);
if (obj && typeof obj.name === 'string' && obj.name.trim()) {
setChipName(obj.name);
}
} catch { /* user is mid-edit, ignore */ }
setChipName((prev) => {
if (prev && prev.trim() && !BLANK_CHIP_NAMES.has(prev.trim())) return prev;
try {
const obj = JSON.parse(chipJson);
if (obj && typeof obj.name === 'string' && obj.name.trim()) return obj.name;
} catch { /* user is mid-edit, ignore */ }
return prev;
});
}, [chipJson]);
const loadExample = (e: ChipExample) => {
setSourceC(e.sourceC);
setChipJson(e.chipJson);
// Loading an example is an explicit "use this chip" — relabel from its
// chip.json name even over a previously-loaded example's name.
try {
const obj = JSON.parse(e.chipJson);
if (obj && typeof obj.name === 'string' && obj.name.trim()) setChipName(obj.name);
} catch { /* keep current name */ }
setWasmBase64(''); // force re-compile so the binary matches the loaded source
setResult(null);
setTab('editor');

View File

@ -9,7 +9,7 @@ import { BOARD_PIN_GROUPS } from '../../simulation/spice/boardPinGroups';
import { CircuitVerificationModal } from '../simulator/CircuitVerificationModal';
import type { PinSourceState } from '../../simulation/spice/types';
import type { BoardKind, LanguageMode } from '../../types/board';
import { BOARD_KIND_FQBN, BOARD_KIND_LABELS, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind } from '../../types/board';
import { BOARD_KIND_FQBN, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, boardDisplayName } from '../../types/board';
import { compileCode } from '../../services/compilation';
import {
compileRom,
@ -421,7 +421,7 @@ export const EditorToolbar = ({
}
const fqbn = kind ? BOARD_KIND_FQBN[kind] : null;
const boardLabel = kind ? BOARD_KIND_LABELS[kind] : 'Unknown';
const boardLabel = activeBoard ? boardDisplayName(activeBoard) : 'Unknown';
if (!fqbn) {
addLog({ timestamp: new Date(), type: 'error', message: `No FQBN for board kind: ${kind}` });
@ -899,7 +899,7 @@ export const EditorToolbar = ({
let failed = 0;
for (const board of boardsList) {
const label = BOARD_KIND_LABELS[board.boardKind] ?? board.boardKind;
const label = boardDisplayName(board);
if (isPiBoardKind(board.boardKind)) {
addLog({
@ -1168,7 +1168,7 @@ export const EditorToolbar = ({
// Architecture mismatch warning for ELF files
if (result.elfInfo?.suggestedBoard && result.elfInfo.suggestedBoard !== boardKind) {
const detected = result.elfInfo.architectureName;
const current = activeBoard ? BOARD_KIND_LABELS[activeBoard.boardKind] : boardKind;
const current = activeBoard ? boardDisplayName(activeBoard) : boardKind;
addLog({
timestamp: new Date(),
type: 'info',

View File

@ -235,6 +235,15 @@
white-space: nowrap;
}
/* Inline rename input for a board/chip section header — flex into the label
slot and match the header's compact type. */
.fe-section-rename-input {
flex: 1;
min-width: 0;
font-size: 11px;
font-weight: 600;
}
.fe-status-dot {
width: 7px;
height: 7px;

View File

@ -9,7 +9,7 @@ import {
DEFAULT_CHIP_PROGRAM_C,
} from '../../services/romCompileService';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
import { boardDisplayName } from '../../types/board';
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
import './FileExplorer.css';
@ -135,6 +135,23 @@ const IcoChevron = ({ open }: { open: boolean }) => (
</svg>
);
// Pencil icon — the rename affordance on a board/chip section header.
const IcoPencil = () => (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
);
// Integrated-circuit (chip) icon — a DIP package with pins. Marks a
// programmable custom-chip's program section, distinct from board sections.
const IcoChip = () => (
@ -285,6 +302,8 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
const boards = useSimulatorStore((s) => s.boards);
const activeBoardId = useSimulatorStore((s) => s.activeBoardId);
const setActiveBoardId = useSimulatorStore((s) => s.setActiveBoardId);
const updateBoard = useSimulatorStore((s) => s.updateBoard);
const updateComponent = useSimulatorStore((s) => s.updateComponent);
const components = useSimulatorStore((s) => s.components);
// Programmable custom-chips (CPU emulators whose chip.json declares
@ -333,6 +352,13 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
const [contextMenu, setContextMenu] = useState<ContextMenu | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// Inline rename of a SECTION header (a board or a chip). Kept separate from
// file rename (renamingId) so the two never collide.
const [renamingSection, setRenamingSection] = useState<{
id: string;
kind: 'board' | 'chip';
} | null>(null);
const [sectionRenameValue, setSectionRenameValue] = useState('');
// Track which board group is creating a file: boardGroupId or null
const [creatingInGroup, setCreatingInGroup] = useState<string | null>(null);
const [newFileName, setNewFileName] = useState('');
@ -340,6 +366,10 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const renameInputRef = useRef<HTMLInputElement>(null);
const sectionRenameInputRef = useRef<HTMLInputElement>(null);
// Set true by Escape so the input's onBlur (which fires when Escape unmounts
// the input) discards instead of committing the typed value.
const sectionRenameCancelledRef = useRef(false);
const newFileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@ -349,6 +379,54 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
}
}, [renamingId]);
useEffect(() => {
if (renamingSection && sectionRenameInputRef.current) {
sectionRenameInputRef.current.focus();
sectionRenameInputRef.current.select();
}
}, [renamingSection]);
const startBoardRename = useCallback((board: { id: string; name?: string; boardKind: BoardKind }) => {
sectionRenameCancelledRef.current = false;
setRenamingSection({ id: board.id, kind: 'board' });
setSectionRenameValue(boardDisplayName(board));
}, []);
const startChipRename = useCallback((chipId: string, currentName: string) => {
sectionRenameCancelledRef.current = false;
setRenamingSection({ id: chipId, kind: 'chip' });
setSectionRenameValue(currentName);
}, []);
const cancelSectionRename = useCallback(() => {
sectionRenameCancelledRef.current = true;
setRenamingSection(null);
}, []);
const commitSectionRename = useCallback(() => {
// Escape cancelled this edit (it unmounts the input, firing onBlur) — discard.
if (sectionRenameCancelledRef.current) {
sectionRenameCancelledRef.current = false;
return;
}
const target = renamingSection;
if (target) {
const value = sectionRenameValue.trim();
if (target.kind === 'board') {
// Empty clears the custom name -> boardDisplayName falls back to kind.
updateBoard(target.id, { name: value });
} else {
const comp = useSimulatorStore.getState().components.find((c) => c.id === target.id);
if (comp) {
updateComponent(target.id, {
properties: { ...comp.properties, chipName: value || 'Custom Chip' },
});
}
}
}
setRenamingSection(null);
}, [renamingSection, sectionRenameValue, updateBoard, updateComponent]);
useEffect(() => {
if (creatingInGroup && newFileInputRef.current) {
newFileInputRef.current.focus();
@ -509,7 +587,7 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
switchToBoard(board.id, groupId);
if (!isOpen) toggleCollapse(board.id);
}}
title={`${BOARD_KIND_LABELS[board.boardKind]} — ${t('editor.fileExplorer.clickToEdit')}`}
title={`${boardDisplayName(board)} — ${t('editor.fileExplorer.clickToEdit')}`}
>
<button
className="fe-collapse-btn"
@ -526,7 +604,31 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
{BOARD_ICON[board.boardKind]}
</span>
<span className="fe-board-label">{BOARD_KIND_LABELS[board.boardKind]}</span>
{renamingSection?.id === board.id && renamingSection.kind === 'board' ? (
<input
ref={sectionRenameInputRef}
className="file-explorer-rename-input fe-section-rename-input"
value={sectionRenameValue}
onChange={(e) => setSectionRenameValue(e.target.value)}
onBlur={commitSectionRename}
onKeyDown={(e) => {
if (e.key === 'Enter') commitSectionRename();
if (e.key === 'Escape') cancelSectionRename();
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="fe-board-label"
onDoubleClick={(e) => {
e.stopPropagation();
startBoardRename(board);
}}
title="Double-click to rename"
>
{boardDisplayName(board)}
</span>
)}
<span
className="fe-status-dot"
@ -540,7 +642,17 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
}
/>
{/* New file button — visible on hover */}
{/* Rename + new-file buttons — visible on hover */}
<button
className="fe-board-new-btn"
title="Rename board (or double-click the name)"
onClick={(e) => {
e.stopPropagation();
startBoardRename(board);
}}
>
<IcoPencil />
</button>
<button
className="fe-board-new-btn"
title={t('editor.fileExplorer.newFileInBoard')}
@ -666,7 +778,44 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
<IcoChip />
</span>
<span className="fe-board-label">{chipName}</span>
{renamingSection?.id === chip.id && renamingSection.kind === 'chip' ? (
<input
ref={sectionRenameInputRef}
className="file-explorer-rename-input fe-section-rename-input"
value={sectionRenameValue}
onChange={(e) => setSectionRenameValue(e.target.value)}
onBlur={commitSectionRename}
onKeyDown={(e) => {
if (e.key === 'Enter') commitSectionRename();
if (e.key === 'Escape') cancelSectionRename();
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="fe-board-label"
onDoubleClick={(e) => {
e.stopPropagation();
startChipRename(chip.id, chipName);
}}
title="Double-click to rename"
>
{chipName}
</span>
)}
{!(renamingSection?.id === chip.id && renamingSection.kind === 'chip') && (
<button
className="fe-board-new-btn"
title="Rename chip (or double-click the name)"
onClick={(e) => {
e.stopPropagation();
startChipRename(chip.id, chipName);
}}
>
<IcoPencil />
</button>
)}
</div>
{isOpen && (

View File

@ -10,6 +10,23 @@
scrollbar-color: #3c3c3c transparent;
}
/* Owner badge — names the board/chip whose files are shown, so the user knows
whose code these tabs belong to. */
.file-tabs-owner {
display: flex;
align-items: center;
padding: 0 10px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #9aa0a6;
background: #181818;
border-right: 1px solid #333;
white-space: nowrap;
flex-shrink: 0;
}
.file-tab {
display: flex;
align-items: center;

View File

@ -1,17 +1,38 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '../../store/useEditorStore';
import { useEditorStore, CHIP_GROUP_PREFIX } from '../../store/useEditorStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { boardDisplayName } from '../../types/board';
import './FileTabs.css';
export const FileTabs: React.FC = () => {
const { t } = useTranslation();
const { files, openFileIds, activeFileId, setActiveFile, closeFile } = useEditorStore();
const { files, openFileIds, activeFileId, activeGroupId, setActiveFile, closeFile } =
useEditorStore();
const [confirmCloseId, setConfirmCloseId] = useState<string | null>(null);
const openFiles = openFileIds
.map((id) => files.find((f) => f.id === id))
.filter(Boolean) as typeof files;
// Which target owns the files currently shown — a board or a custom chip —
// so the user can tell whose code they're editing at a glance. Resolved as a
// SELECTOR returning just the label string, so FileTabs only re-renders when
// that label changes — not on every pin toggle that mutates the components
// array during simulation.
const ownerLabel = useSimulatorStore((s): string | null => {
if (activeGroupId?.startsWith(CHIP_GROUP_PREFIX)) {
const chipId = activeGroupId.slice(CHIP_GROUP_PREFIX.length);
const chip = s.components.find((c) => c.id === chipId);
return (
String((chip?.properties as Record<string, unknown>)?.chipName ?? '').trim() ||
'Custom Chip'
);
}
const board = s.boards.find((b) => b.activeFileGroupId === activeGroupId);
return board ? boardDisplayName(board) : null;
});
const handleCloseClick = (e: React.MouseEvent, fileId: string, modified: boolean) => {
e.stopPropagation();
if (modified) {
@ -29,6 +50,11 @@ export const FileTabs: React.FC = () => {
return (
<>
<div className="file-tabs">
{ownerLabel && (
<span className="file-tabs-owner" title={`These files belong to ${ownerLabel}`}>
{ownerLabel}
</span>
)}
{openFiles.map((file) => (
<div
key={file.id}

View File

@ -25,6 +25,8 @@ interface BoardOptionsModalProps {
isOpen: boolean;
boardId: string;
boardKind: BoardKind;
/** User-given board name (optional); shown in the subtitle when set. */
boardName?: string;
currentOptions: ESP32BoardOptions | undefined;
spiffsFiles: SpiffsFile[];
onClose: () => void;
@ -70,6 +72,7 @@ async function readFileAsBase64(file: File): Promise<string> {
export const BoardOptionsModal = ({
isOpen,
boardId,
boardName,
boardKind,
currentOptions,
spiffsFiles,
@ -85,6 +88,12 @@ export const BoardOptionsModal = ({
const [draft, setDraft] = useState<ESP32BoardOptions>(seed);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const update = useCallback(
(patch: Partial<ESP32BoardOptions>) => setDraft((d) => ({ ...d, ...patch })),
[],
);
// Hooks must run on every render — bail out only AFTER all of them.
if (!isOpen) return null;
const showPsram = boardSupportsPsram(boardKind);
@ -97,11 +106,6 @@ export const BoardOptionsModal = ({
const nonDio = draft.flashMode !== 'dio';
const sameCore = draft.eventsRunOnCore === draft.arduinoRunsOnCore;
const update = useCallback(
(patch: Partial<ESP32BoardOptions>) => setDraft((d) => ({ ...d, ...patch })),
[],
);
const handleApply = () => {
// Strip PSRAM for boards that don't support it (e.g. C3) so a stale
// value can't smuggle through to the backend.
@ -146,7 +150,7 @@ export const BoardOptionsModal = ({
<div className="bom-title">
<div className="bom-title-main">Board Options</div>
<div className="bom-title-sub">
{BOARD_KIND_LABELS[boardKind]} - id {boardId}
{boardName?.trim() || BOARD_KIND_LABELS[boardKind]} - id {boardId}
</div>
</div>
<button className="bom-close" onClick={onClose} aria-label="Close">

View File

@ -22,7 +22,7 @@ import {
type TriggerEdge,
} from '../../store/useOscilloscopeStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { BOARD_KIND_LABELS } from '../../types/board';
import { boardDisplayName } from '../../types/board';
import type { BoardKind } from '../../types/board';
import './Oscilloscope.css';
@ -342,16 +342,16 @@ const ChannelPicker: React.FC<ChannelPickerProps> = ({
key={b.id}
className={`osc-picker-board-tab${b.id === selectedBoard?.id ? ' active' : ''}`}
onClick={() => setSelectedBoardId(b.id)}
title={BOARD_KIND_LABELS[b.boardKind]}
title={boardDisplayName(b)}
>
{b.id}
{boardDisplayName(b)}
</button>
))}
</div>
{/* Board label */}
{selectedBoard && (
<div className="osc-picker-board-label">{BOARD_KIND_LABELS[selectedBoard.boardKind]}</div>
<div className="osc-picker-board-label">{boardDisplayName(selectedBoard)}</div>
)}
{/* Pin grid */}

View File

@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { getTabSessionId } from '../../simulation/Esp32Bridge';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
import { boardDisplayName } from '../../types/board';
// Short labels for tabs
const BOARD_SHORT_LABEL: Partial<Record<string, string>> = {
@ -207,12 +207,12 @@ export const SerialMonitor: React.FC = () => {
...(isActive ? { ...styles.tabActive, borderBottomColor: color, color } : {}),
}}
onClick={() => handleTabClick(board.id)}
title={BOARD_KIND_LABELS[board.boardKind]}
title={boardDisplayName(board)}
>
<span style={{ fontSize: 9, marginRight: 3, color: isActive ? color : '#888' }}>
{BOARD_ICON[board.boardKind] ?? '●'}
</span>
{BOARD_SHORT_LABEL[board.boardKind] ?? board.boardKind}
{board.name?.trim() || BOARD_SHORT_LABEL[board.boardKind] || board.boardKind}
{hasUnread && !isActive && <span style={styles.unreadDot} />}
</button>
);

View File

@ -42,7 +42,7 @@ import {
import { useIsCoarsePointer } from '../../utils/useTouchDevice';
import type { ComponentMetadata } from '../../types/component-metadata';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_FQBN, BOARD_KIND_LABELS } from '../../types/board';
import { BOARD_KIND_FQBN, boardDisplayName } from '../../types/board';
import { boardGateDecision, proBoardFeatureName, triggerProUpgradePrompt } from '../../lib/proBoardGate';
import { FlashModal } from './FlashModal';
import { isTauri as isTauriRuntimeFn } from '../../desktop/tauriBridge';
@ -2066,7 +2066,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
>
{boards.map((b) => (
<option key={b.id} value={b.id}>
{BOARD_KIND_LABELS[b.boardKind] ?? b.id}
{boardDisplayName(b)}
</option>
))}
</select>
@ -2607,7 +2607,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
let title = pinPicker.kind === 'board' ? 'Board' : 'Component';
if (pinPicker.kind === 'board') {
const b = boards.find((x) => x.id === id);
if (b) title = BOARD_KIND_LABELS[b.boardKind] ?? b.boardKind;
if (b) title = boardDisplayName(b);
} else {
const c = components.find((x) => x.id === id);
const meta = c ? registry.getById(c.metadataId) : null;
@ -2809,7 +2809,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
{boardContextMenu &&
(() => {
const board = boards.find((b) => b.id === boardContextMenu.boardId);
const label = board ? BOARD_KIND_LABELS[board.boardKind] : 'Board';
const label = board ? boardDisplayName(board) : 'Board';
const connectedWires = wires.filter(
(w) =>
w.start.componentId === boardContextMenu.boardId ||
@ -3045,6 +3045,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
<BoardOptionsModal
isOpen={true}
boardId={b.id}
boardName={b.name}
boardKind={b.boardKind}
currentOptions={b.boardOptions}
spiffsFiles={b.spiffsFiles ?? []}
@ -3059,7 +3060,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
{boardToRemove &&
(() => {
const board = boards.find((b) => b.id === boardToRemove);
const label = board ? BOARD_KIND_LABELS[board.boardKind] : t('editor.canvas.removeConfirm.boardFallback');
const label = board ? boardDisplayName(board) : t('editor.canvas.removeConfirm.boardFallback');
const connectedWires = wires.filter(
(w) => w.start.componentId === boardToRemove || w.end.componentId === boardToRemove,
).length;

View File

@ -152,6 +152,7 @@ export function buildLoadPayload(project: RawProject) {
if (Array.isArray(parsed) && parsed.length > 0) {
boards = parsed.map((b: Partial<BoardInstance> & { id: string; boardKind: string }) => ({
id: b.id,
name: b.name,
boardKind: b.boardKind as BoardKind,
x: b.x ?? 50,
y: b.y ?? 50,

View File

@ -1277,11 +1277,12 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
payload.boards.forEach((b) => {
addBoard(b.boardKind, b.x, b.y, b.id);
// Apply the rest of the saved fields that addBoard doesn't set.
if (b.languageMode && b.languageMode !== 'arduino') {
const patch: Partial<BoardInstance> = {};
if (b.languageMode && b.languageMode !== 'arduino') patch.languageMode = b.languageMode;
if (b.name && b.name.trim()) patch.name = b.name;
if (Object.keys(patch).length > 0) {
set((s) => ({
boards: s.boards.map((bb) =>
bb.id === b.id ? { ...bb, languageMode: b.languageMode } : bb,
),
boards: s.boards.map((bb) => (bb.id === b.id ? { ...bb, ...patch } : bb)),
}));
}
});

View File

@ -75,6 +75,10 @@ export interface BleStatus {
export interface BoardInstance {
id: string; // unique in canvas, e.g. 'arduino-uno', 'raspberry-pi-3'
/** Optional user-given display name. Falls back to the kind label when
* empty. Lets the user tell two same-kind boards apart in the file
* explorer, the compile console, and the canvas selector. */
name?: string;
boardKind: BoardKind;
x: number;
y: number;
@ -129,6 +133,13 @@ export const BOARD_KIND_LABELS: Record<BoardKind, string> = {
attiny85: 'ATtiny85',
};
/** Display name for a board instance: the user's custom name if set, else the
* kind label. Route every user-facing board label through this so renamed
* boards show their name everywhere (file explorer, compile console, canvas). */
export function boardDisplayName(board: Pick<BoardInstance, 'name' | 'boardKind'>): string {
return board.name?.trim() || BOARD_KIND_LABELS[board.boardKind];
}
export const BOARD_KIND_FQBN: Record<BoardKind, string | null> = {
'arduino-uno': 'arduino:avr:uno',
'arduino-nano': 'arduino:avr:nano:cpu=atmega328',

View File

@ -31,6 +31,7 @@ function chipGroupIdsWithFiles(): string[] {
function serialisableBoard(b: BoardInstance) {
return {
id: b.id,
name: b.name,
boardKind: b.boardKind,
x: b.x,
y: b.y,

View File

@ -45,6 +45,7 @@ export interface VlxPayload {
name?: string;
boards: Array<{
id: string;
name?: string;
boardKind: string;
x: number;
y: number;
@ -61,6 +62,7 @@ export interface VlxPayload {
function serialisableBoard(b: BoardInstance) {
return {
id: b.id,
name: b.name,
boardKind: b.boardKind,
x: b.x,
y: b.y,