From 8617d3b2240a603560cd93c84a3c3a0eba239f41 Mon Sep 17 00:00:00 2001 From: David Montero Date: Sun, 7 Jun 2026 06:07:48 +0200 Subject: [PATCH] feat(library-manifest): per-board manifests + autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library manifests are now PER-BOARD (each board carries its own velxio.json), so two boards in one project can use different (even conflicting) libraries without clashing — the multi-board extension of the no-clash guarantee. - board.libraries on BoardInstance + serialisableBoard: rides in boards_json, so it round-trips, dirty-checks, autosaves and restores natively. This also removes the load-restore hacks (useLibraryManifestStore + applyProjectManifest deleted): the manifest is plain board state. - loadProjectState now restores per-board boardOptions/spiffsFiles/libraries (it previously dropped them). - EditorToolbar single + compile-all send the COMPILING board's libraries. - Backend compile.py prefers the client's per-board request.libraries; the project-level libraries_json (now the union of all boards) is the fallback. - buildLoadPayload migrates pre-per-board projects: seed each board with the project union so they keep compiling scoped. - Library Manager 'In project' tab edits the ACTIVE board's velxio.json (shows the board name) and the add field is now an autocomplete (installed libs + index search) so users pick from a list instead of typing names. Deletes useLibraryManifestStore.ts + applyProjectManifest.ts. --- backend/app/api/routes/compile.py | 21 ++- .../src/components/editor/EditorToolbar.tsx | 9 +- .../src/components/editor/FileExplorer.tsx | 4 +- .../simulator/LibraryManagerModal.tsx | 174 ++++++++++++++---- frontend/src/pages/ProjectByIdPage.tsx | 31 +++- frontend/src/store/useLibraryManifestStore.ts | 24 --- frontend/src/store/useSimulatorStore.ts | 4 + frontend/src/types/board.ts | 5 + frontend/src/utils/applyProjectManifest.ts | 19 -- frontend/src/utils/loadExample.ts | 17 +- frontend/src/utils/projectPayload.ts | 41 +++-- 11 files changed, 228 insertions(+), 121 deletions(-) delete mode 100644 frontend/src/store/useLibraryManifestStore.ts delete mode 100644 frontend/src/utils/applyProjectManifest.ts diff --git a/backend/app/api/routes/compile.py b/backend/app/api/routes/compile.py index 20c5bf7b..996a5dab 100644 --- a/backend/app/api/routes/compile.py +++ b/backend/app/api/routes/compile.py @@ -215,17 +215,20 @@ async def _run_compile( [f.model_dump() for f in request.spiffs_files] if request.spiffs_files else None ) - # Library manifest = ESP-IDF resolution SCOPE. Prefer the manifest SAVED - # with the project (authoritative, read server-side from the project - # record) so a reloaded project always scopes to its own libraries - # regardless of what the client sends; fall back to the manifest the - # client sent (unsaved examples). None/empty → legacy scan-all. + # Library manifest = ESP-IDF resolution SCOPE. Manifests are now + # PER-BOARD (each board carries its own velxio.json), and the client + # sends the COMPILING board's manifest in request.libraries — so it + # takes precedence: two boards in one project can scope to different + # libraries. Fall back to the project-level manifest (the union of all + # boards, read server-side) only when the client sends none — e.g. an + # anonymous compile or an old client. None/empty → legacy scan-all. allowed_libraries = None - project_libs = await get_project_libraries(request.project_id) - if project_libs: - allowed_libraries = set(project_libs) - elif request.libraries: + if request.libraries: allowed_libraries = set(request.libraries) + else: + project_libs = await get_project_libraries(request.project_id) + if project_libs: + allowed_libraries = set(project_libs) result = await espidf_compiler.compile( files, request.board_fqbn, progress_callback=progress_callback, diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index ca6bd12b..dec9a7e8 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -22,7 +22,6 @@ import { clearChipDrives } from '../../simulation/customChips/chipPinDrives'; import { requestElectricalResolve } from '../../simulation/spice/electricalResolveHook'; import { reportRunEvent } from '../../services/metricsService'; import { useProjectStore } from '../../store/useProjectStore'; -import { useLibraryManifestStore } from '../../store/useLibraryManifestStore'; import { LibraryManagerModal } from '../simulator/LibraryManagerModal'; import { InstallLibrariesModal } from '../simulator/InstallLibrariesModal'; import { parseCompileResult } from '../../utils/compilationLogger'; @@ -171,8 +170,6 @@ export const EditorToolbar = ({ const activeBoard = boards.find((b) => b.id === activeBoardId) ?? boards[0]; const currentProject = useProjectStore((s) => s.currentProject); - // P2.3 — declared library manifest of the loaded example/project (compile scope). - const activeLibraries = useLibraryManifestStore((s) => s.libraries); // Board-less mode: digital / analog SPICE-only circuits. The Run / Stop // buttons toggle the SPICE solver's `paused` flag — pausing freezes every @@ -520,7 +517,9 @@ export const EditorToolbar = ({ { boardOptions: activeBoard?.boardOptions, spiffsFiles: activeBoard?.spiffsFiles, - libraries: activeLibraries, + // P2.4 — THIS board's declared manifest (compile scope). Per-board so + // two boards can use different libraries without clashing. + libraries: activeBoard?.libraries?.length ? activeBoard.libraries : null, }, ); @@ -993,7 +992,7 @@ export const EditorToolbar = ({ })), ]); }, - { boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: activeLibraries }, + { boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null }, ); const resultLogs = parseCompileResult(result, label, boardTarget); diff --git a/frontend/src/components/editor/FileExplorer.tsx b/frontend/src/components/editor/FileExplorer.tsx index 6a22fdfa..92f89298 100644 --- a/frontend/src/components/editor/FileExplorer.tsx +++ b/frontend/src/components/editor/FileExplorer.tsx @@ -2,7 +2,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore'; import { useSimulatorStore } from '../../store/useSimulatorStore'; -import { useLibraryManifestStore } from '../../store/useLibraryManifestStore'; import { isProgrammableChip, targetForChip, @@ -300,9 +299,10 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl renameFile, setActiveGroup, } = useEditorStore(); - const manifestLibs = useLibraryManifestStore((s) => s.libraries); const boards = useSimulatorStore((s) => s.boards); const activeBoardId = useSimulatorStore((s) => s.activeBoardId); + // P2.4 — velxio.json is per-board: show the ACTIVE board's declared manifest. + const manifestLibs = boards.find((b) => b.id === activeBoardId)?.libraries ?? null; const setActiveBoardId = useSimulatorStore((s) => s.setActiveBoardId); const updateBoard = useSimulatorStore((s) => s.updateBoard); const updateComponent = useSimulatorStore((s) => s.updateComponent); diff --git a/frontend/src/components/simulator/LibraryManagerModal.tsx b/frontend/src/components/simulator/LibraryManagerModal.tsx index 797c5fca..606bc84b 100644 --- a/frontend/src/components/simulator/LibraryManagerModal.tsx +++ b/frontend/src/components/simulator/LibraryManagerModal.tsx @@ -8,7 +8,8 @@ import { } from '../../services/libraryService'; import type { ArduinoLibrary, InstalledLibrary } from '../../services/libraryService'; import { trackInstallLibrary } from '../../utils/analytics'; -import { useLibraryManifestStore } from '../../store/useLibraryManifestStore'; +import { useSimulatorStore } from '../../store/useSimulatorStore'; +import { boardDisplayName } from '../../types/board'; import './LibraryManagerModal.css'; interface LibraryManagerModalProps { @@ -27,13 +28,27 @@ const normLib = (s: string): string => export const LibraryManagerModal: React.FC = ({ isOpen, onClose }) => { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('search'); - // P2.4 — the project's declared library manifest (compile scope) lives here. - const manifestLibs = useLibraryManifestStore((s) => s.libraries); - const setLibraries = useLibraryManifestStore((s) => s.setLibraries); + // P2.4 — manifests are PER-BOARD: the velxio.json edited here belongs to the + // ACTIVE board, so two boards in one project can scope to different libraries. + const boards = useSimulatorStore((s) => s.boards); + const activeBoardId = useSimulatorStore((s) => s.activeBoardId); + const updateBoard = useSimulatorStore((s) => s.updateBoard); + const activeBoard = boards.find((b) => b.id === activeBoardId) ?? boards[0]; + const manifestLibs = activeBoard?.libraries ?? null; + const setLibraries = useCallback( + (libs: string[] | null) => { + if (!activeBoard) return; + updateBoard(activeBoard.id, { libraries: libs && libs.length ? libs : undefined }); + }, + [activeBoard, updateBoard], + ); // Raw velxio.json editor draft + parse error (the Wokwi-style view). const [jsonDraft, setJsonDraft] = useState(''); const [jsonError, setJsonError] = useState(null); const [newLibName, setNewLibName] = useState(''); + // Autocomplete suggestions for the "add library" field (index search). + const [addSuggestions, setAddSuggestions] = useState([]); + const addDebounceRef = useRef | null>(null); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [installedLibraries, setInstalledLibraries] = useState([]); @@ -163,22 +178,62 @@ export const LibraryManagerModal: React.FC = ({ isOpen (name: string) => { const clean = name.trim(); if (!clean) return; - const cur = useLibraryManifestStore.getState().libraries ?? []; + const cur = manifestLibs ?? []; if (cur.some((l) => normLib(l) === normLib(clean))) return; setLibraries([...cur, clean]); }, - [setLibraries], + [manifestLibs, setLibraries], ); const removeFromManifest = useCallback( (name: string) => { - const cur = useLibraryManifestStore.getState().libraries ?? []; + const cur = manifestLibs ?? []; const next = cur.filter((l) => normLib(l) !== normLib(name)); setLibraries(next.length ? next : null); }, - [setLibraries], + [manifestLibs, setLibraries], ); + // Autocomplete: debounced index search for the "add library" field. Combined + // with instant matches from the installed list in `addOptions` below. + useEffect(() => { + if (addDebounceRef.current) clearTimeout(addDebounceRef.current); + const q = newLibName.trim(); + if (q.length < 2) { + setAddSuggestions([]); + return; + } + addDebounceRef.current = setTimeout(async () => { + try { + const results = await searchLibraries(q); + setAddSuggestions(results.map((r) => r.name).filter(Boolean)); + } catch { + setAddSuggestions([]); + } + }, 300); + return () => { + if (addDebounceRef.current) clearTimeout(addDebounceRef.current); + }; + }, [newLibName]); + + // Merged, de-duped suggestions: installed libs first (instant), then index + // results, excluding what's already declared. Capped for a tidy dropdown. + const addOptions = (() => { + const q = normLib(newLibName); + if (!q) return []; + const installedNames = installedLibraries + .map((il) => il.library?.name || il.name || '') + .filter(Boolean); + const merged: string[] = []; + for (const n of [...installedNames, ...addSuggestions]) { + if (!normLib(n).includes(q)) continue; + if (declared.some((d) => normLib(d) === normLib(n))) continue; + if (merged.some((m) => normLib(m) === normLib(n))) continue; + merged.push(n); + } + return merged.slice(0, 8); + })(); + const applyJsonDraft = useCallback(() => { try { const parsed = JSON.parse(jsonDraft || '{}'); @@ -334,10 +389,14 @@ export const LibraryManagerModal: React.FC = ({ isOpen {activeTab === 'project' && (
- Libraries this project uses (its velxio.json). - They become the compile scope so libraries never clash between - projects. Installing a library from the Search tab adds it here - automatically; you can also add or remove them below. + Libraries used by{' '} + + {activeBoard ? boardDisplayName(activeBoard) : 'this board'} + {' '} + (its velxio.json). Each + board has its own list, so two boards can use different libraries + without clashing. Installing a library adds it here automatically; + start typing below to add more.
{/* Declared libraries as removable rows */} @@ -371,30 +430,73 @@ export const LibraryManagerModal: React.FC = ({ isOpen ))}
- {/* Quick add by name */} -
- setNewLibName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && newLibName.trim()) { - addToManifest(newLibName); - setNewLibName(''); - } - }} - /> - + {/* Add a library — autocomplete (installed + index search) so the + user picks from a list instead of typing the exact name. */} +
+
+ setNewLibName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const pick = addOptions[0] ?? newLibName; + if (pick.trim()) { + addToManifest(pick); + setNewLibName(''); + setAddSuggestions([]); + } + } else if (e.key === 'Escape') { + setNewLibName(''); + setAddSuggestions([]); + } + }} + /> +
+ {addOptions.length > 0 && ( +
+ {addOptions.map((opt) => ( + + ))} +
+ )}
{/* Raw velxio.json editor (Wokwi-style) for power users */} diff --git a/frontend/src/pages/ProjectByIdPage.tsx b/frontend/src/pages/ProjectByIdPage.tsx index 1d78a27c..cc8edda3 100644 --- a/frontend/src/pages/ProjectByIdPage.tsx +++ b/frontend/src/pages/ProjectByIdPage.tsx @@ -3,7 +3,6 @@ import { useParams, useNavigate } from 'react-router-dom'; import { getProjectById } from '../services/projectService'; import { useSimulatorStore } from '../store/useSimulatorStore'; import { useProjectStore } from '../store/useProjectStore'; -import { applyProjectManifest } from '../utils/applyProjectManifest'; import { useSEO } from '../utils/useSEO'; import { EditorPage } from './EditorPage'; import type { BoardInstance, BoardKind } from '../types/board'; @@ -56,12 +55,10 @@ export const ProjectByIdPage: React.FC = () => { getProjectById(id) .then((project) => { const payload = buildLoadPayload(project); + // Per-board manifests ride in boards_json (buildLoadPayload migrates + // pre-per-board projects), so loadProjectState restores each board's + // compile scope directly. loadProjectState(payload); - // P2.4 — restore the declared library manifest (compile scope) so the - // editor, toolbar, Library Manager and velxio.json reflect it. Done via - // a dedicated side-effecting util (not inside buildLoadPayload) so the - // store write survives esbuild's DCE — see applyProjectManifest. - applyProjectManifest(project.libraries_json); setCurrentProject({ id: project.id, slug: project.slug, @@ -174,6 +171,8 @@ export function buildLoadPayload(project: RawProject) { // pre-feature projects; the compiler falls back to its defaults. boardOptions: b.boardOptions, spiffsFiles: b.spiffsFiles, + // P2.4 — this board's declared manifest (per-board compile scope). + libraries: b.libraries, })); } } catch { @@ -199,6 +198,20 @@ export function buildLoadPayload(project: RawProject) { ]; } + // P2.4 migration — projects saved before per-board manifests stored a single + // project-level manifest (libraries_json). If no board carries its own, seed + // every board with the project union so it keeps compiling scoped. + if (!boards.some((b) => b.libraries && b.libraries.length)) { + try { + const union = JSON.parse(project.libraries_json || '[]'); + if (Array.isArray(union) && union.length) { + for (const b of boards) b.libraries = union as string[]; + } + } catch { + // ignore + } + } + // File groups const fileGroups: Record = {}; if (project.file_groups && project.file_groups.length > 0) { @@ -237,9 +250,9 @@ export function buildLoadPayload(project: RawProject) { wires = []; } - // NB: the project's declared library manifest (project.libraries_json) is - // NOT restored here — esbuild DCE'd the store write when it lived in this - // value-producer helper. The caller applies it via applyProjectManifest(). + // Per-board library manifests ride inside each board (boards_json) and were + // migrated above for pre-per-board projects, so loadProjectState restores the + // compile scope along with the boards — no separate step needed. return { boards, fileGroups, diff --git a/frontend/src/store/useLibraryManifestStore.ts b/frontend/src/store/useLibraryManifestStore.ts deleted file mode 100644 index 6300b94f..00000000 --- a/frontend/src/store/useLibraryManifestStore.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { create } from 'zustand'; - -/** - * Library manifest for the currently-loaded example/project (P2.3). - * - * The declared library set is sent to the backend compiler as the resolution - * SCOPE: ESP-IDF then merges only these libraries (plus the core), so a sketch - * picks the declared lib and never an unrelated one from the shared dir. - * - * `null` = no manifest -> legacy scan-all (unchanged behaviour). Set by - * `loadExample` to the example's declared `libraries` (or null for core-only - * examples). Switching between examples updates it; loading a non-example - * workspace leaves the previous value, but that only degrades to scan-all via - * the backend's graceful fallback — never an incorrect build. - */ -interface LibraryManifestState { - libraries: string[] | null; - setLibraries: (libs: string[] | null | undefined) => void; -} - -export const useLibraryManifestStore = create((set) => ({ - libraries: null, - setLibraries: (libs) => set({ libraries: libs && libs.length ? libs : null }), -})); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index d14cd175..c1e30867 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -1280,6 +1280,10 @@ export const useSimulatorStore = create((set, get) => { const patch: Partial = {}; if (b.languageMode && b.languageMode !== 'arduino') patch.languageMode = b.languageMode; if (b.name && b.name.trim()) patch.name = b.name; + // P2.4 — restore per-board persisted fields that ride in boards_json. + if (b.boardOptions) patch.boardOptions = b.boardOptions; + if (b.spiffsFiles) patch.spiffsFiles = b.spiffsFiles; + if (b.libraries && b.libraries.length) patch.libraries = b.libraries; if (Object.keys(patch).length > 0) { set((s) => ({ boards: s.boards.map((bb) => (bb.id === b.id ? { ...bb, ...patch } : bb)), diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts index 18b43b37..d97c949c 100644 --- a/frontend/src/types/board.ts +++ b/frontend/src/types/board.ts @@ -98,6 +98,11 @@ export interface BoardInstance { // Types live in `./boardOptions` to avoid a circular import. boardOptions?: import('./boardOptions').ESP32BoardOptions; spiffsFiles?: import('./boardOptions').SpiffsFile[]; + // P2.4 — this board's declared library manifest (its velxio.json). The ESP32 + // compile scope: each board resolves ONLY its own declared libraries, so two + // boards in the same project can use different (even conflicting) libraries + // without clashing. Undefined for pre-feature boards (-> legacy scan-all). + libraries?: string[]; } export const BOARD_KIND_LABELS: Record = { diff --git a/frontend/src/utils/applyProjectManifest.ts b/frontend/src/utils/applyProjectManifest.ts deleted file mode 100644 index 9b9c7441..00000000 --- a/frontend/src/utils/applyProjectManifest.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useLibraryManifestStore } from '../store/useLibraryManifestStore'; - -/** - * Apply a saved project's declared library manifest (the compile scope) to the - * manifest store on load, so the editor, toolbar, Library Manager and - * velxio.json all reflect it. `null`/empty -> no scope (legacy scan-all). - * - * Lives in its own util so both the OSS and the pro-overlay ProjectByIdPage - * (the one actually routed on velxio.dev) restore the manifest identically. - */ -export function applyProjectManifest(librariesJson: string | undefined | null): void { - try { - const parsed = JSON.parse(librariesJson || '[]'); - const libs = Array.isArray(parsed) && parsed.length ? (parsed as string[]) : null; - useLibraryManifestStore.getState().setLibraries(libs); - } catch { - useLibraryManifestStore.getState().setLibraries(null); - } -} diff --git a/frontend/src/utils/loadExample.ts b/frontend/src/utils/loadExample.ts index 05599f79..c62ba038 100644 --- a/frontend/src/utils/loadExample.ts +++ b/frontend/src/utils/loadExample.ts @@ -11,7 +11,6 @@ import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulator import { useElectricalStore } from '../store/useElectricalStore'; import { useProjectStore } from '../store/useProjectStore'; import { useVfsStore } from '../store/useVfsStore'; -import { useLibraryManifestStore } from '../store/useLibraryManifestStore'; import { isBoardComponent } from './boardPinMapping'; import { getInstalledLibraries, installLibrary } from '../services/libraryService'; import { trackOpenExample } from './analytics'; @@ -116,10 +115,8 @@ export async function loadExample( // so no PUT goes out. useProjectStore.getState().clearCurrentProject(); - // P2.3 — record this example's declared library manifest as the compile - // scope (null for core-only examples). EditorToolbar sends it with every - // compile so ESP-IDF resolution merges exactly these libraries. - useLibraryManifestStore.getState().setLibraries(example.libraries ?? null); + // P2.4 — this example's declared manifest (compile scope) is assigned to each + // board it creates at the END of this function (the boards don't exist yet). // Loading a new example always starts unpaused — otherwise the canvas // would open with every LED frozen at the previous example's state. @@ -377,4 +374,14 @@ export async function loadExample( ); recalculateAllWirePositions(); } + + // P2.4 — assign this example's declared manifest to every board it created + // (the per-board compile scope). Examples declare one library set today, so + // each board gets it; the user can refine per board via velxio.json. + { + const sim = useSimulatorStore.getState(); + const libs = + example.libraries && example.libraries.length ? example.libraries : undefined; + for (const b of sim.boards) sim.updateBoard(b.id, { libraries: libs }); + } } diff --git a/frontend/src/utils/projectPayload.ts b/frontend/src/utils/projectPayload.ts index 52716bff..5715640a 100644 --- a/frontend/src/utils/projectPayload.ts +++ b/frontend/src/utils/projectPayload.ts @@ -11,7 +11,6 @@ import type { BoardInstance } from '../types/board'; import type { Wire } from '../types/wire'; import { useEditorStore, chipFileGroupId } from '../store/useEditorStore'; import { useSimulatorStore } from '../store/useSimulatorStore'; -import { useLibraryManifestStore } from '../store/useLibraryManifestStore'; /** * Editor groups owned by programmable custom-chips on the canvas (those whose @@ -45,9 +44,29 @@ function serialisableBoard(b: BoardInstance) { // inside boards_json so there's no DB migration. boardOptions: b.boardOptions, spiffsFiles: b.spiffsFiles, + // P2.4 — this board's declared library manifest (compile scope). Rides in + // boards_json so it round-trips, dirty-checks and autosaves for free. + libraries: b.libraries, }; } +/** Union of every board's declared library manifest, sorted + de-duped. + * Persisted as the project-level `libraries_json` for backward-compat readers + * and as the backend's fallback scope when a client sends no per-board list. */ +function unionBoardLibraries(boards: BoardInstance[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const b of boards) { + for (const lib of b.libraries ?? []) { + if (!seen.has(lib)) { + seen.add(lib); + out.push(lib); + } + } + } + return out.sort(); +} + interface SnapshotInputs { name?: string; description?: string; @@ -97,12 +116,12 @@ export function buildSavePayload(meta: SnapshotInputs = {}): ProjectSaveData { })); const fileGroups = [...boardGroups, ...chipGroups]; - // P2.4 — declared library manifest (compile scope). Persist it ONLY when it - // is explicitly known (non-null). null means "unknown" — e.g. a reloaded - // project whose manifest wasn't restored into the store — so we OMIT the - // field and the backend preserves the saved manifest instead of clobbering - // it to []. The compiler reads the saved manifest server-side regardless. - const manifestLibs = useLibraryManifestStore.getState().libraries; + // P2.4 — per-board manifests live inside boards_json (serialisableBoard). + // The project-level libraries_json is their UNION: kept for backward-compat + // readers and as the backend's fallback compile scope when a client sends no + // per-board list. Always sent (no clobber risk: boards_json is the source of + // truth and round-trips natively, so the union is always recomputable). + const unionLibs = unionBoardLibraries(sim.boards); return { name: meta.name ?? '', @@ -115,7 +134,7 @@ export function buildSavePayload(meta: SnapshotInputs = {}): ProjectSaveData { components_json: JSON.stringify(sim.components), wires_json: JSON.stringify(sim.wires), boards_json: JSON.stringify(sim.boards.map(serialisableBoard)), - ...(manifestLibs !== null ? { libraries_json: JSON.stringify(manifestLibs) } : {}), + libraries_json: JSON.stringify(unionLibs), }; } @@ -152,15 +171,13 @@ export function computeProjectStateHash(): string { })); const payload = { + // boards carry their per-board `libraries` via serialisableBoard, so + // declaring/removing a library marks the project dirty and autosaves. boards: sim.boards.map(serialisableBoard), activeId: sim.activeBoardId, components: sim.components, wires: wiresHash, groups: groupsForHash, - // P2.4 — include the declared library manifest so adding/removing a - // library (e.g. via the Library Manager / velxio.json) marks the project - // dirty and gets persisted by the auto-save hook, even with no code change. - libraries: useLibraryManifestStore.getState().libraries, }; return JSON.stringify(payload); }