feat(library-manifest): per-board manifests + autocomplete

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.
This commit is contained in:
David Montero 2026-06-07 06:07:48 +02:00
parent c93924541c
commit 8617d3b224
11 changed files with 228 additions and 121 deletions

View File

@ -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,

View File

@ -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);

View File

@ -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<FileExplorerProps> = ({ 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);

View File

@ -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<LibraryManagerModalProps> = ({ isOpen, onClose }) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<Tab>('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<string | null>(null);
const [newLibName, setNewLibName] = useState('');
// Autocomplete suggestions for the "add library" field (index search).
const [addSuggestions, setAddSuggestions] = useState<string[]>([]);
const addDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<ArduinoLibrary[]>([]);
const [installedLibraries, setInstalledLibraries] = useState<InstalledLibrary[]>([]);
@ -163,22 +178,62 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ 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<LibraryManagerModalProps> = ({ isOpen
{activeTab === 'project' && (
<div className="lib-content">
<div style={{ padding: '10px 14px', color: '#9d9d9d', fontSize: 12, lineHeight: 1.5 }}>
Libraries this project uses (its <strong style={{ color: '#ffd60a' }}>velxio.json</strong>).
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{' '}
<strong style={{ color: '#a5d6a7' }}>
{activeBoard ? boardDisplayName(activeBoard) : 'this board'}
</strong>{' '}
(its <strong style={{ color: '#ffd60a' }}>velxio.json</strong>). 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.
</div>
{/* Declared libraries as removable rows */}
@ -371,30 +430,73 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
))}
</div>
{/* Quick add by name */}
<div className="lib-search-bar" style={{ marginTop: 8 }}>
<input
type="text"
placeholder="Add a library by name (e.g. Adafruit GFX Library)"
value={newLibName}
onChange={(e) => setNewLibName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newLibName.trim()) {
addToManifest(newLibName);
setNewLibName('');
}
}}
/>
<button
className="lib-install-btn"
disabled={!newLibName.trim()}
onClick={() => {
addToManifest(newLibName);
setNewLibName('');
}}
>
Add
</button>
{/* Add a library autocomplete (installed + index search) so the
user picks from a list instead of typing the exact name. */}
<div style={{ position: 'relative', marginTop: 8 }}>
<div className="lib-search-bar" style={{ margin: 0 }}>
<input
type="text"
placeholder="Add a library — start typing to search…"
value={newLibName}
onChange={(e) => 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([]);
}
}}
/>
</div>
{addOptions.length > 0 && (
<div
style={{
position: 'absolute',
left: 0,
right: 0,
zIndex: 5,
background: '#252526',
border: '1px solid #3c3c3c',
borderRadius: 4,
marginTop: 2,
maxHeight: 200,
overflowY: 'auto',
boxShadow: '0 6px 16px rgba(0,0,0,0.4)',
}}
>
{addOptions.map((opt) => (
<button
key={opt}
onClick={() => {
addToManifest(opt);
setNewLibName('');
setAddSuggestions([]);
}}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
padding: '7px 12px',
background: 'transparent',
border: 'none',
color: '#d4d4d4',
fontSize: 13,
cursor: 'pointer',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#094771')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
{opt}
</button>
))}
</div>
)}
</div>
{/* Raw velxio.json editor (Wokwi-style) for power users */}

View File

@ -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<string, { name: string; content: string }[]> = {};
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,

View File

@ -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<LibraryManifestState>((set) => ({
libraries: null,
setLibraries: (libs) => set({ libraries: libs && libs.length ? libs : null }),
}));

View File

@ -1280,6 +1280,10 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
const patch: Partial<BoardInstance> = {};
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)),

View File

@ -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<BoardKind, string> = {

View File

@ -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);
}
}

View File

@ -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 });
}
}

View File

@ -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<string>();
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);
}