feat(custom-chip): program lives in its own editor group, not the board sketch
A programmable custom-chip (a CPU emulator that runs a ROM/program, e.g. the Z80 or 8080) now keeps its program (larson.s, chaser.c, ...) in a dedicated editor file group — group-chip-<chipId> — rendered as its own collapsible section in the file explorer, exactly like each board owns its sketch group. Behaviour/driver chips and predefined chips carry no programFile and get no group; they stay editable only in the chip designer. Fixes two reported issues on the Z80 examples: - /example/z80-larson-no-board: the board-less chip example now opens its program (larson.s) as the active group, editable on the left — previously the editor showed but no file appeared. - /example/z80-led-chaser-c: the chip program (chaser.c) no longer shows as a sibling tab inside the Arduino sketch group; it sits in its own chip section instead. The board group shows only sketch.ino. Details: - useEditorStore: chipFileGroupId()/CHIP_GROUP_PREFIX helpers. - loadExample: seedChipProgramGroups() routes each chip's programFile into its own group (seeded from the example files), sweeps stale chip groups, keeps the program OUT of the board group, and for a board-less chip example makes the chip group active so the program is the editable file shown. - EditorToolbar.prepareCustomChips: resolves the program from the chip's own group (falls back to board files for older projects) before assembling ROM. - FileExplorer: renders one collapsible section per programmable chip with an IC icon; clicking switches the editor to the chip group. Lazy-creates a group for chips dropped on the canvas. - projectPayload + vlxFile: serialise chip groups alongside board groups and include them in the dirty-check hash, so chip-program edits persist on save / autosave / .vlx export and round-trip via replaceFileGroups on load. - Regression tests for board-less + board+chip routing and stale-group sweep. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
fe001d728c
commit
5a23e89eb5
|
|
@ -83,3 +83,59 @@ describe('loadExample — board-less → board-based transition', () => {
|
|||
expect(code, 'editor must contain the Uno example body').toContain(uno.code.slice(0, 40));
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadExample — programmable-chip program lives in its own group', () => {
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
});
|
||||
|
||||
it('board-less chip example opens the chip program (larson.s) as the active group, editable', async () => {
|
||||
await loadExample(findExample('z80-larson-no-board'));
|
||||
|
||||
const ed = useEditorStore.getState();
|
||||
expect(useSimulatorStore.getState().boards.length).toBe(0);
|
||||
|
||||
// The chip owns a group-chip-<id> group with its program file.
|
||||
const chipGroupId = 'group-chip-z80cpu';
|
||||
expect(ed.fileGroups[chipGroupId], 'chip group exists').toBeDefined();
|
||||
expect(ed.fileGroups[chipGroupId].map((f) => f.name)).toContain('larson.s');
|
||||
|
||||
// That group is the active one (program shows on the left, editable).
|
||||
expect(ed.activeGroupId).toBe(chipGroupId);
|
||||
const larson = ed.fileGroups[chipGroupId].find((f) => f.name === 'larson.s');
|
||||
expect(larson?.content.length ?? 0, 'larson.s is non-empty').toBeGreaterThan(0);
|
||||
expect(activeSketchContent(), 'editor shows the larson.s program').toBe(larson?.content);
|
||||
});
|
||||
|
||||
it('board + chip example keeps the chip program OUT of the board sketch group', async () => {
|
||||
await loadExample(findExample('z80-led-chaser-c'));
|
||||
|
||||
const ed = useEditorStore.getState();
|
||||
const sim = useSimulatorStore.getState();
|
||||
|
||||
// Board group shows only the sketch — chaser.c is NOT a sibling tab.
|
||||
const board = sim.boards.find((b) => b.id === sim.activeBoardId) ?? sim.boards[0];
|
||||
const boardFiles = (ed.fileGroups[board.activeFileGroupId] ?? []).map((f) => f.name);
|
||||
expect(boardFiles).toContain('sketch.ino');
|
||||
expect(boardFiles, 'chaser.c must not pollute the board group').not.toContain('chaser.c');
|
||||
|
||||
// The chip program lives in its own group instead.
|
||||
const chipGroupId = 'group-chip-z80cpu';
|
||||
expect(ed.fileGroups[chipGroupId]?.map((f) => f.name)).toContain('chaser.c');
|
||||
|
||||
// With a board present the board sketch stays the active group.
|
||||
expect(ed.activeGroupId).toBe(board.activeFileGroupId);
|
||||
});
|
||||
|
||||
it('chip groups from a previous example do not leak into the next', async () => {
|
||||
await loadExample(findExample('z80-led-chaser-c'));
|
||||
expect(useEditorStore.getState().fileGroups['group-chip-z80cpu']).toBeDefined();
|
||||
|
||||
// A plain board example with no custom chip must clear the stale chip group.
|
||||
await loadExample(findExample('blink-led'));
|
||||
expect(
|
||||
useEditorStore.getState().fileGroups['group-chip-z80cpu'],
|
||||
'stale chip group swept on next load',
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEditorStore } from '../../store/useEditorStore';
|
||||
import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore';
|
||||
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
||||
import { useElectricalStore } from '../../store/useElectricalStore';
|
||||
import { verifyCircuit, type VerificationResult } from '../../simulation/verify/circuitVerifier';
|
||||
|
|
@ -259,12 +259,21 @@ export const EditorToolbar = ({
|
|||
// when there's no ROM yet or the user edited code since last build.
|
||||
const programFile = String(props.programFile ?? '').trim();
|
||||
if (programFile && (!String(props.romBytes ?? '') || codeChanged)) {
|
||||
const file = boardFiles.find((f) => f.name === programFile);
|
||||
// The program lives in the chip's OWN editor group (its collapsible
|
||||
// section in the file explorer), separate from the board sketch.
|
||||
// Fall back to the board files for older projects that still carried
|
||||
// the program alongside sketch.ino in the board group.
|
||||
const chipGroupFiles = useEditorStore
|
||||
.getState()
|
||||
.getGroupFiles(chipFileGroupId(chip.id));
|
||||
const file =
|
||||
chipGroupFiles.find((f) => f.name === programFile) ??
|
||||
boardFiles.find((f) => f.name === programFile);
|
||||
if (!file) {
|
||||
addLog({
|
||||
timestamp: new Date(),
|
||||
type: 'error',
|
||||
message: `Chip "${chipLabel}": program file "${programFile}" not found in this board's files.`,
|
||||
message: `Chip "${chipLabel}": program file "${programFile}" not found in the chip's files.`,
|
||||
});
|
||||
} else {
|
||||
const target = targetForChip(chipJson);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEditorStore } from '../../store/useEditorStore';
|
||||
import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore';
|
||||
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
||||
import type { BoardKind } from '../../types/board';
|
||||
import { BOARD_KIND_LABELS } from '../../types/board';
|
||||
|
|
@ -129,6 +129,31 @@ const IcoChevron = ({ open }: { open: boolean }) => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
// Integrated-circuit (chip) icon — a DIP package with pins. Marks a
|
||||
// programmable custom-chip's program section, distinct from board sections.
|
||||
const IcoChip = () => (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||
<line x1="10" y1="3" x2="10" y2="7" />
|
||||
<line x1="14" y1="3" x2="14" y2="7" />
|
||||
<line x1="10" y1="17" x2="10" y2="21" />
|
||||
<line x1="14" y1="17" x2="14" y2="21" />
|
||||
<line x1="3" y1="10" x2="7" y2="10" />
|
||||
<line x1="3" y1="14" x2="7" y2="14" />
|
||||
<line x1="17" y1="10" x2="21" y2="10" />
|
||||
<line x1="17" y1="14" x2="21" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Board emoji icons — mirrors BoardPickerModal
|
||||
const BOARD_ICON: Record<BoardKind, string> = {
|
||||
'arduino-uno': '⬤',
|
||||
|
|
@ -254,6 +279,33 @@ 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 components = useSimulatorStore((s) => s.components);
|
||||
|
||||
// Programmable custom-chips (those with a `programFile`) own a program the
|
||||
// user can edit — a ROM source / C — shown as its own section below the
|
||||
// boards. Behaviour/driver chips and predefined chips carry no programFile
|
||||
// and don't appear here (they're edited in the chip designer).
|
||||
const programmableChips = components.filter(
|
||||
(c) =>
|
||||
c.metadataId === 'custom-chip' &&
|
||||
String((c.properties as Record<string, unknown>)?.programFile ?? '').trim() !== '',
|
||||
);
|
||||
|
||||
// Ensure each programmable chip has its editor group. loadExample seeds these
|
||||
// from the example's files; this is the safety net for chips dropped onto the
|
||||
// canvas (or older projects) — create an empty program file to edit.
|
||||
useEffect(() => {
|
||||
const ed = useEditorStore.getState();
|
||||
for (const chip of programmableChips) {
|
||||
const gid = chipFileGroupId(chip.id);
|
||||
if (ed.fileGroups[gid]) continue;
|
||||
const pf = String((chip.properties as Record<string, unknown>).programFile ?? '').trim();
|
||||
if (!pf) continue;
|
||||
const seed = String((chip.properties as Record<string, unknown>).programSource ?? '');
|
||||
ed.createFileGroup(gid, [{ name: pf, content: seed }]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [components]);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenu | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
|
|
@ -308,6 +360,23 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
|
|||
[activeBoardId, switchToBoard, openFile],
|
||||
);
|
||||
|
||||
// Chip program groups aren't tied to a board — switching to one just makes
|
||||
// the chip's group active in the editor (no activeBoardId change).
|
||||
const switchToChip = useCallback(
|
||||
(groupId: string) => {
|
||||
setActiveGroup(groupId);
|
||||
},
|
||||
[setActiveGroup],
|
||||
);
|
||||
|
||||
const handleChipFileClick = useCallback(
|
||||
(fileId: string, groupId: string) => {
|
||||
if (groupId !== activeGroupId) switchToChip(groupId);
|
||||
openFile(fileId);
|
||||
},
|
||||
[activeGroupId, switchToChip, openFile],
|
||||
);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, fileId: string, boardGroupId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -536,8 +605,76 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
|
|||
);
|
||||
})}
|
||||
|
||||
{/* Fallback: no boards yet */}
|
||||
{boards.length === 0 && (
|
||||
{/* Programmable custom-chip program sections — one per chip, each its
|
||||
own collapsible group (the chip's ROM source / C), separate from the
|
||||
board sketch above. */}
|
||||
{programmableChips.map((chip) => {
|
||||
const groupId = chipFileGroupId(chip.id);
|
||||
const groupFiles = fileGroups[groupId] ?? [];
|
||||
if (groupFiles.length === 0) return null;
|
||||
const isActiveGroup = activeGroupId === groupId;
|
||||
const isOpen = !collapsed[chip.id];
|
||||
const chipName =
|
||||
String((chip.properties as Record<string, unknown>)?.chipName ?? '').trim() ||
|
||||
'Custom Chip';
|
||||
|
||||
return (
|
||||
<div key={chip.id} className="fe-board-section">
|
||||
<div
|
||||
className={`fe-board-header${isActiveGroup ? ' fe-board-header-active' : ''}`}
|
||||
onClick={() => {
|
||||
switchToChip(groupId);
|
||||
if (!isOpen) toggleCollapse(chip.id);
|
||||
}}
|
||||
title={`${chipName} — ${t('editor.fileExplorer.clickToEdit')}`}
|
||||
>
|
||||
<button
|
||||
className="fe-collapse-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCollapse(chip.id);
|
||||
}}
|
||||
title={isOpen ? t('editor.fileExplorer.collapse') : t('editor.fileExplorer.expand')}
|
||||
>
|
||||
<IcoChevron open={isOpen} />
|
||||
</button>
|
||||
|
||||
<span className="fe-board-icon" style={{ color: '#c4b5fd' }}>
|
||||
<IcoChip />
|
||||
</span>
|
||||
|
||||
<span className="fe-board-label">{chipName}</span>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="fe-board-files">
|
||||
{groupFiles.map((file) => {
|
||||
const isActiveFile = isActiveGroup && file.id === activeFileId;
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`file-explorer-item fe-file-item${isActiveFile ? ' file-explorer-item-active' : ''}`}
|
||||
onClick={() => handleChipFileClick(file.id, groupId)}
|
||||
title={`${file.name}${file.modified ? ` (${t('editor.fileExplorer.unsavedSuffix')})` : ''}`}
|
||||
>
|
||||
<span className="file-explorer-icon">
|
||||
<FileIcon name={file.name} />
|
||||
</span>
|
||||
<span className="file-explorer-name">{file.name}</span>
|
||||
{file.modified && (
|
||||
<span className="file-explorer-dot" title={t('editor.fileExplorer.unsavedChanges')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Fallback: nothing on the canvas yet */}
|
||||
{boards.length === 0 && programmableChips.length === 0 && (
|
||||
<div style={{ color: '#666', fontSize: 11, padding: '12px 12px', lineHeight: 1.5 }}>
|
||||
{t('editor.fileExplorer.emptyState')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -625,8 +625,9 @@ export const retroIntelExamples: ExampleProject[] = [
|
|||
// ── Z80 Larson Scanner — NO board (general-purpose electronics) ──
|
||||
// A programmable Z80 chip + 8 LEDs + a regulated power supply, with NO
|
||||
// Arduino/ESP32 on the canvas. Demonstrates that Velxio runs custom chips
|
||||
// standalone. Board-less (boardFilter: 'digital'); the ROM is pre-baked so
|
||||
// only the chip WASM compiles on Run.
|
||||
// standalone. Board-less (boardFilter: 'digital'); larson.s is the chip's
|
||||
// editable program (its own section in the file explorer) — Run assembles
|
||||
// it to ROM and compiles the chip WASM.
|
||||
{
|
||||
id: 'z80-larson-no-board',
|
||||
title: 'Z80 Larson Scanner (no board)',
|
||||
|
|
|
|||
|
|
@ -78,6 +78,21 @@ const DEFAULT_FILE: WorkspaceFile = {
|
|||
/** Default file group for the initial Arduino Uno board */
|
||||
const DEFAULT_GROUP_ID = 'group-arduino-uno';
|
||||
|
||||
/**
|
||||
* Editor file group id for a programmable custom-chip's program.
|
||||
*
|
||||
* A custom chip that loads a ROM / runs a user program (a CPU emulator such
|
||||
* as the Z80 or 8080) keeps that program (`larson.s`, `chaser.c`, …) in its
|
||||
* OWN file group, exactly like each board owns one. The file explorer renders
|
||||
* it as a separate collapsible section, so the chip's program never gets
|
||||
* mixed into the board's sketch. Behaviour/driver chips (a servo driver, a
|
||||
* sensor) and predefined chips carry no program file and get no group — they
|
||||
* are edited in the chip designer instead.
|
||||
*/
|
||||
export const chipFileGroupId = (chipId: string): string => `group-chip-${chipId}`;
|
||||
/** Prefix shared by every chip program group — used to sweep stale ones. */
|
||||
export const CHIP_GROUP_PREFIX = 'group-chip-';
|
||||
|
||||
/**
|
||||
* Editor view layout. Lets the user collapse either pane to give the chat
|
||||
* (right-docked) more breathing room, or to focus on one half of the
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import type { ExampleProject } from '../data/examples';
|
||||
import type { BoardKind } from '../types/board';
|
||||
import { isPiBoardKind } from '../types/board';
|
||||
import { useEditorStore } from '../store/useEditorStore';
|
||||
import { useEditorStore, chipFileGroupId, CHIP_GROUP_PREFIX } from '../store/useEditorStore';
|
||||
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
|
||||
import { useElectricalStore } from '../store/useElectricalStore';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
|
|
@ -50,6 +50,45 @@ export async function ensureLibraries(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmable custom-chips (those with a `programFile`) keep their program
|
||||
* (ROM source / C) in their OWN editor file group — group-chip-<chipId> — so
|
||||
* it shows as a separate collapsible section in the file explorer, never mixed
|
||||
* into the board's sketch. This mirrors how every board owns a group.
|
||||
*
|
||||
* Seeds those groups from the example's `files[]` (matched by programFile
|
||||
* name), clearing any chip groups left over from a previously-loaded example.
|
||||
* Returns the set of program filenames (so the caller keeps them OUT of the
|
||||
* board's own group) and the created group ids (so a board-less example can
|
||||
* open the program as the active group).
|
||||
*/
|
||||
function seedChipProgramGroups(example: ExampleProject): {
|
||||
programFileNames: Set<string>;
|
||||
chipGroupIds: string[];
|
||||
} {
|
||||
const editor = useEditorStore.getState();
|
||||
// Drop chip groups from a previously-loaded example so they don't linger.
|
||||
Object.keys(editor.fileGroups)
|
||||
.filter((g) => g.startsWith(CHIP_GROUP_PREFIX))
|
||||
.forEach((g) => editor.deleteFileGroup(g));
|
||||
|
||||
const programFileNames = new Set<string>();
|
||||
const chipGroupIds: string[] = [];
|
||||
for (const comp of example.components) {
|
||||
if (stripBrandPrefix(comp.type) !== 'custom-chip') continue;
|
||||
const pf = String(
|
||||
(comp.properties as Record<string, unknown>)?.programFile ?? '',
|
||||
).trim();
|
||||
if (!pf) continue; // behaviour / predefined chips have no editable program
|
||||
programFileNames.add(pf);
|
||||
const content = example.files?.find((f) => f.name === pf)?.content ?? '';
|
||||
const gid = chipFileGroupId(comp.id);
|
||||
editor.createFileGroup(gid, [{ name: pf, content }]);
|
||||
chipGroupIds.push(gid);
|
||||
}
|
||||
return { programFileNames, chipGroupIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an example project into the editor + simulator stores.
|
||||
* Does NOT navigate — the caller is responsible for navigation.
|
||||
|
|
@ -155,6 +194,10 @@ export async function loadExample(
|
|||
setActiveBoardId(boardIds[firstArduinoIdx]);
|
||||
}
|
||||
|
||||
// Programmable chips own their program in a dedicated editor group so it
|
||||
// shows as its own section (the per-board code came from eb.code above).
|
||||
seedChipProgramGroups(example);
|
||||
|
||||
const componentsWithoutBoard = example.components.filter(
|
||||
(comp) =>
|
||||
!comp.type.includes('arduino') &&
|
||||
|
|
@ -213,7 +256,15 @@ export async function loadExample(
|
|||
setActiveBoardId(newId);
|
||||
}
|
||||
|
||||
// ── MicroPython + multi-file payloads ────────────────────────────────
|
||||
// ── Program / file routing ───────────────────────────────────────────
|
||||
// A programmable chip's program (larson.s, chaser.c, …) goes into the
|
||||
// chip's OWN editor group — its own collapsible section — never into the
|
||||
// board's sketch group. Everything else (sketch.ino) stays with the board.
|
||||
const { programFileNames, chipGroupIds } = seedChipProgramGroups(example);
|
||||
const boardOwnedFiles = (example.files ?? []).filter(
|
||||
(f) => !programFileNames.has(f.name),
|
||||
);
|
||||
|
||||
// When the example specifies languageMode='micropython' or ships a
|
||||
// files[] array, we go through setBoardLanguageMode + loadFiles instead
|
||||
// of the legacy setCode() path so the editor opens the right file
|
||||
|
|
@ -227,42 +278,40 @@ export async function loadExample(
|
|||
setBoardLanguageMode(liveBoard.id, 'micropython');
|
||||
}
|
||||
|
||||
if (example.files && example.files.length > 0 && liveBoard) {
|
||||
// Re-resolve the file group ID — setBoardLanguageMode replaces it.
|
||||
const editorStore = useEditorStore.getState();
|
||||
if (liveBoard) {
|
||||
// Board present: the board group shows the sketch; chip programs sit in
|
||||
// their own sections. Re-resolve the group ID — setBoardLanguageMode
|
||||
// replaces it. We use `loadFiles` (not legacy `setCode`) and switch the
|
||||
// editor to the board's group first: after a board-less → board
|
||||
// transition the editor's `activeFileId` still points at an orphan ID
|
||||
// from the deleted group, so `setCode` would silently no-op and the
|
||||
// editor would appear blank. (Regression: load-example-transitions.test.ts.)
|
||||
const updatedBoard = useSimulatorStore
|
||||
.getState()
|
||||
.boards.find((b) => b.id === liveBoard.id);
|
||||
const groupId = updatedBoard?.activeFileGroupId ?? liveBoard.activeFileGroupId;
|
||||
const editorStore = useEditorStore.getState();
|
||||
editorStore.setActiveGroup(groupId);
|
||||
editorStore.loadFiles(example.files);
|
||||
} else if (liveBoard) {
|
||||
// Single-file Arduino-style example. We must use `loadFiles` (not the
|
||||
// legacy `setCode`) and explicitly switch the editor store to the
|
||||
// board's file group: in board-less → board transitions the editor's
|
||||
// `activeFileId` still points at an orphan ID from the deleted
|
||||
// group, so `setCode` would silently no-op and the editor would
|
||||
// appear blank. (Regression test: load-example-transitions.test.ts.)
|
||||
const editorStore = useEditorStore.getState();
|
||||
editorStore.setActiveGroup(liveBoard.activeFileGroupId);
|
||||
const filename = isPiBoardKind(liveBoard.boardKind) ? 'main.cpp' : 'sketch.ino';
|
||||
editorStore.loadFiles([{ name: filename, content: example.code }]);
|
||||
} else {
|
||||
// Truly board-less. There is no board file-group, so point the editor at
|
||||
// the default group and load the example's files THERE — otherwise the
|
||||
// editor's activeGroupId still points at a deleted board's group and
|
||||
// setCode silently no-ops, leaving the editor blank/uneditable. This is
|
||||
// what lets a board-less custom-chip example show its program (.s/.c) on
|
||||
// the left, editable, just like the board-backed examples.
|
||||
const editorStore = useEditorStore.getState();
|
||||
if (example.files && example.files.length > 0) {
|
||||
editorStore.setActiveGroup('group-arduino-uno'); // = DEFAULT_GROUP_ID
|
||||
editorStore.loadFiles(example.files);
|
||||
if (boardOwnedFiles.length > 0) {
|
||||
editorStore.loadFiles(boardOwnedFiles);
|
||||
} else {
|
||||
// Pure analog/digital circuits ship no editable program — keep the
|
||||
// legacy behaviour (write the placeholder to the current file).
|
||||
editorStore.setCode(example.code);
|
||||
const filename = isPiBoardKind(liveBoard.boardKind) ? 'main.cpp' : 'sketch.ino';
|
||||
editorStore.loadFiles([{ name: filename, content: example.code }]);
|
||||
}
|
||||
} else if (chipGroupIds.length > 0) {
|
||||
// Board-less custom-chip example. The chip's program IS the only code
|
||||
// here, so open its group on the left, editable — just like an Arduino
|
||||
// sketch. (This is what makes /example/z80-larson-no-board show larson.s.)
|
||||
editorStore.setActiveGroup(chipGroupIds[0]);
|
||||
} else if (boardOwnedFiles.length > 0) {
|
||||
// Board-less but ships plain files (rare) — point the editor at the
|
||||
// default group and load them there so it isn't blank/uneditable.
|
||||
editorStore.setActiveGroup('group-arduino-uno'); // = DEFAULT_GROUP_ID
|
||||
editorStore.loadFiles(boardOwnedFiles);
|
||||
} else {
|
||||
// Pure analog/digital circuit, no editable program — keep the legacy
|
||||
// behaviour (write the placeholder to the current file).
|
||||
editorStore.setCode(example.code);
|
||||
}
|
||||
|
||||
const componentsWithoutBoard = example.components.filter(
|
||||
|
|
|
|||
|
|
@ -9,9 +9,24 @@
|
|||
import type { ProjectSaveData } from '../services/projectService';
|
||||
import type { BoardInstance } from '../types/board';
|
||||
import type { Wire } from '../types/wire';
|
||||
import { useEditorStore } from '../store/useEditorStore';
|
||||
import { useEditorStore, chipFileGroupId } from '../store/useEditorStore';
|
||||
import { useSimulatorStore } from '../store/useSimulatorStore';
|
||||
|
||||
/**
|
||||
* Editor groups owned by programmable custom-chips on the canvas (those whose
|
||||
* `group-chip-<id>` group exists). A chip's program (ROM source / C) lives in
|
||||
* its own group, exactly like a board sketch, so it must be serialised and
|
||||
* dirty-checked alongside the board groups — otherwise saving would drop it.
|
||||
*/
|
||||
function chipGroupIdsWithFiles(): string[] {
|
||||
const sim = useSimulatorStore.getState();
|
||||
const editor = useEditorStore.getState();
|
||||
return sim.components
|
||||
.filter((c) => c.metadataId === 'custom-chip')
|
||||
.map((c) => chipFileGroupId(c.id))
|
||||
.filter((gid) => (editor.fileGroups[gid]?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
/** Strip BoardInstance down to JSON-safe fields (drop runtime state). */
|
||||
function serialisableBoard(b: BoardInstance) {
|
||||
return {
|
||||
|
|
@ -62,13 +77,23 @@ export function buildSavePayload(meta: SnapshotInputs = {}): ProjectSaveData {
|
|||
activeFiles[0]?.content ??
|
||||
'';
|
||||
|
||||
const fileGroups = sim.boards.map((b) => ({
|
||||
const boardGroups = sim.boards.map((b) => ({
|
||||
groupId: b.activeFileGroupId,
|
||||
files: (editor.fileGroups[b.activeFileGroupId] ?? []).map((f) => ({
|
||||
name: f.name,
|
||||
content: f.content,
|
||||
})),
|
||||
}));
|
||||
// Programmable-chip program groups round-trip too — same shape, restored on
|
||||
// load by replaceFileGroups (the group id is derived from the chip's id).
|
||||
const chipGroups = chipGroupIdsWithFiles().map((gid) => ({
|
||||
groupId: gid,
|
||||
files: (editor.fileGroups[gid] ?? []).map((f) => ({
|
||||
name: f.name,
|
||||
content: f.content,
|
||||
})),
|
||||
}));
|
||||
const fileGroups = [...boardGroups, ...chipGroups];
|
||||
|
||||
return {
|
||||
name: meta.name ?? '',
|
||||
|
|
@ -98,7 +123,7 @@ export function computeProjectStateHash(): string {
|
|||
// Group file contents only for groups referenced by an existing board, in
|
||||
// a stable order (sorted by groupId).
|
||||
const referencedGroups = Array.from(
|
||||
new Set(sim.boards.map((b) => b.activeFileGroupId)),
|
||||
new Set([...sim.boards.map((b) => b.activeFileGroupId), ...chipGroupIdsWithFiles()]),
|
||||
).sort();
|
||||
const groupsForHash = referencedGroups.map((gid) => ({
|
||||
g: gid,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
import type { BoardInstance } from '../types/board';
|
||||
import type { Component } from '../types/component';
|
||||
import type { Wire } from '../types/wire';
|
||||
import { useEditorStore } from '../store/useEditorStore';
|
||||
import { useEditorStore, chipFileGroupId } from '../store/useEditorStore';
|
||||
import { useSimulatorStore } from '../store/useSimulatorStore';
|
||||
|
||||
const VLX_FORMAT = 'velxio-project';
|
||||
|
|
@ -78,9 +78,15 @@ export function buildVlxPayload(opts: { name?: string } = {}): VlxPayload {
|
|||
const sim = useSimulatorStore.getState();
|
||||
const editor = useEditorStore.getState();
|
||||
|
||||
// Only persist file groups that are actually referenced by a board.
|
||||
// Stray groups left over from deleted boards don't need to round-trip.
|
||||
// Persist file groups referenced by a board, plus each programmable chip's
|
||||
// own program group (group-chip-<id>) — otherwise the chip's program would
|
||||
// be dropped on export. Stray groups from deleted boards don't round-trip.
|
||||
const referencedGroupIds = new Set(sim.boards.map((b) => b.activeFileGroupId));
|
||||
for (const c of sim.components) {
|
||||
if (c.metadataId !== 'custom-chip') continue;
|
||||
const gid = chipFileGroupId(c.id);
|
||||
if (editor.fileGroups[gid]?.length) referencedGroupIds.add(gid);
|
||||
}
|
||||
const fileGroups: VlxPayload['fileGroups'] = {};
|
||||
for (const gid of referencedGroupIds) {
|
||||
fileGroups[gid] = (editor.fileGroups[gid] ?? []).map((f) => ({
|
||||
|
|
|
|||
Loading…
Reference in New Issue