diff --git a/frontend/src/__tests__/load-example-transitions.test.ts b/frontend/src/__tests__/load-example-transitions.test.ts index 457b02e4..2ba5e422 100644 --- a/frontend/src/__tests__/load-example-transitions.test.ts +++ b/frontend/src/__tests__/load-example-transitions.test.ts @@ -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- 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(); + }); +}); diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 5267fa00..16d148bd 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -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); diff --git a/frontend/src/components/editor/FileExplorer.tsx b/frontend/src/components/editor/FileExplorer.tsx index 3183ce26..0f356794 100644 --- a/frontend/src/components/editor/FileExplorer.tsx +++ b/frontend/src/components/editor/FileExplorer.tsx @@ -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 }) => ( ); +// Integrated-circuit (chip) icon — a DIP package with pins. Marks a +// programmable custom-chip's program section, distinct from board sections. +const IcoChip = () => ( + + + + + + + + + + + +); + // Board emoji icons — mirrors BoardPickerModal const BOARD_ICON: Record = { 'arduino-uno': '⬤', @@ -254,6 +279,33 @@ export const FileExplorer: React.FC = ({ 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)?.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).programFile ?? '').trim(); + if (!pf) continue; + const seed = String((chip.properties as Record).programSource ?? ''); + ed.createFileGroup(gid, [{ name: pf, content: seed }]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [components]); const [contextMenu, setContextMenu] = useState(null); const [renamingId, setRenamingId] = useState(null); @@ -308,6 +360,23 @@ export const FileExplorer: React.FC = ({ 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 = ({ 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)?.chipName ?? '').trim() || + 'Custom Chip'; + + return ( +
+
{ + switchToChip(groupId); + if (!isOpen) toggleCollapse(chip.id); + }} + title={`${chipName} — ${t('editor.fileExplorer.clickToEdit')}`} + > + + + + + + + {chipName} +
+ + {isOpen && ( +
+ {groupFiles.map((file) => { + const isActiveFile = isActiveGroup && file.id === activeFileId; + return ( +
handleChipFileClick(file.id, groupId)} + title={`${file.name}${file.modified ? ` (${t('editor.fileExplorer.unsavedSuffix')})` : ''}`} + > + + + + {file.name} + {file.modified && ( + + )} +
+ ); + })} +
+ )} +
+ ); + })} + + {/* Fallback: nothing on the canvas yet */} + {boards.length === 0 && programmableChips.length === 0 && (
{t('editor.fileExplorer.emptyState')}
diff --git a/frontend/src/data/examples-retro-intel.ts b/frontend/src/data/examples-retro-intel.ts index b93dc67b..5b4c5350 100644 --- a/frontend/src/data/examples-retro-intel.ts +++ b/frontend/src/data/examples-retro-intel.ts @@ -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)', diff --git a/frontend/src/store/useEditorStore.ts b/frontend/src/store/useEditorStore.ts index ad35e7e1..4871b5b8 100644 --- a/frontend/src/store/useEditorStore.ts +++ b/frontend/src/store/useEditorStore.ts @@ -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 diff --git a/frontend/src/utils/loadExample.ts b/frontend/src/utils/loadExample.ts index d4d09074..1fa1edd2 100644 --- a/frontend/src/utils/loadExample.ts +++ b/frontend/src/utils/loadExample.ts @@ -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- — 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; + 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(); + const chipGroupIds: string[] = []; + for (const comp of example.components) { + if (stripBrandPrefix(comp.type) !== 'custom-chip') continue; + const pf = String( + (comp.properties as Record)?.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( diff --git a/frontend/src/utils/projectPayload.ts b/frontend/src/utils/projectPayload.ts index ac942f76..8191b5f9 100644 --- a/frontend/src/utils/projectPayload.ts +++ b/frontend/src/utils/projectPayload.ts @@ -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-` 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, diff --git a/frontend/src/utils/vlxFile.ts b/frontend/src/utils/vlxFile.ts index 8cdaf11a..b7f9138a 100644 --- a/frontend/src/utils/vlxFile.ts +++ b/frontend/src/utils/vlxFile.ts @@ -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-) — 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) => ({