import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { searchLibraries, installLibrary, getInstalledLibraries, getCustomLibraries, deleteCustomLibrary, uninstallLibrary, } from '../../services/libraryService'; import type { ArduinoLibrary, InstalledLibrary } from '../../services/libraryService'; import { trackInstallLibrary } from '../../utils/analytics'; import { useSimulatorStore } from '../../store/useSimulatorStore'; import { boardDisplayName } from '../../types/board'; import './LibraryManagerModal.css'; interface LibraryManagerModalProps { isOpen: boolean; onClose: () => void; } type Tab = 'project' | 'search' | 'installed'; /** Case/separator-insensitive name match, matching the backend's * _norm_lib_name (lowercased, alphanumerics only) so the UI's "in project" * state agrees with what the compiler scopes. */ const normLib = (s: string): string => (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); export const LibraryManagerModal: React.FC = ({ isOpen, onClose }) => { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('search'); // 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([]); const [loadingSearch, setLoadingSearch] = useState(false); const [loadingInstalled, setLoadingInstalled] = useState(false); const [installingLib, setInstallingLib] = useState(null); const [uninstallingLib, setUninstallingLib] = useState(null); /** Track user-selected version per library name */ const [selectedVersions, setSelectedVersions] = useState>({}); const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>( null, ); const debounceRef = useRef | null>(null); const fetchInstalled = useCallback(async () => { setLoadingInstalled(true); try { // P2.2c — the shared global list (index libs) PLUS the user's per-user // custom uploads (which live in their per-user store, not the global // list). Custom first so they're easy to find; de-duped by name. const [libs, custom] = await Promise.all([getInstalledLibraries(), getCustomLibraries()]); const customNames = new Set(custom.map((c) => (c.name || '').toLowerCase())); const merged = [ ...custom, ...libs.filter((l) => !customNames.has((l.library?.name || l.name || '').toLowerCase())), ]; setInstalledLibraries(merged); } catch (e: unknown) { setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Failed to load installed libraries', }); } finally { setLoadingInstalled(false); } }, []); // Reset state when modal closes useEffect(() => { if (!isOpen) { setSearchQuery(''); setSearchResults([]); setStatusMsg(null); } }, [isOpen]); // Fetch installed list when modal opens or switching to installed tab useEffect(() => { if (isOpen && activeTab === 'installed') fetchInstalled(); }, [isOpen, activeTab, fetchInstalled]); useEffect(() => { if (isOpen) fetchInstalled(); }, [isOpen, fetchInstalled]); // Search: immediate on open (empty query), debounced when typing useEffect(() => { if (!isOpen) return; if (debounceRef.current) clearTimeout(debounceRef.current); const delay = searchQuery ? 400 : 0; debounceRef.current = setTimeout(async () => { setLoadingSearch(true); setStatusMsg(null); try { const results = await searchLibraries(searchQuery); setSearchResults(results); } catch (e: unknown) { setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Search failed' }); setSearchResults([]); } finally { setLoadingSearch(false); } }, delay); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [searchQuery, isOpen]); const handleInstall = async (libName: string) => { setInstallingLib(libName); setStatusMsg(null); try { const version = selectedVersions[libName]; const result = await installLibrary(libName, version); if (result.success) { trackInstallLibrary(libName); // P2.4 — installing a library declares it for THIS project (adds it to // velxio.json), so the compile is scoped to it and it never clashes // with another project's libs. The user can remove it in the Project tab. addToManifest(libName); if (result.fallback) { setStatusMsg({ type: 'success', text: `"${libName}" installed and added to this project (latest — requested @${result.requested_version} was not available)` }); } else { setStatusMsg({ type: 'success', text: `"${libName}${version ? ' @' + version : ''}" installed and added to this project!` }); } fetchInstalled(); } else { setStatusMsg({ type: 'error', text: result.error || `Failed to install "${libName}"` }); } } catch (e: unknown) { setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Installation failed' }); } finally { setInstallingLib(null); } }; const handleUninstall = async (libName: string) => { setUninstallingLib(libName); setStatusMsg(null); try { const result = await uninstallLibrary(libName); if (result.success) { setStatusMsg({ type: 'success', text: `"${libName}" uninstalled successfully!` }); fetchInstalled(); } else { setStatusMsg({ type: 'error', text: result.error || `Failed to uninstall "${libName}"` }); } } catch (e: unknown) { setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Uninstall failed' }); } finally { setUninstallingLib(null); } }; // P2.2c — a CUSTOM lib lives in the user's per-user store, not the shared // arduino-cli dir, so removing it hits the per-user delete endpoint. const handleRemoveCustom = async (libName: string) => { setUninstallingLib(libName); setStatusMsg(null); try { const result = await deleteCustomLibrary(libName); if (result.success) { setStatusMsg({ type: 'success', text: `Removed your custom "${libName}".` }); removeFromManifest(libName); fetchInstalled(); } else { setStatusMsg({ type: 'error', text: result.error || `Failed to remove "${libName}"` }); } } finally { setUninstallingLib(null); } }; // ── Project manifest (velxio.json) editing ────────────────────────────── const declared = manifestLibs ?? []; const inManifest = useCallback( (name: string): boolean => declared.some((l) => normLib(l) === normLib(name)), [declared], ); const addToManifest = useCallback( (name: string) => { const clean = name.trim(); if (!clean) return; const cur = manifestLibs ?? []; if (cur.some((l) => normLib(l) === normLib(clean))) return; setLibraries([...cur, clean]); }, [manifestLibs, setLibraries], ); const removeFromManifest = useCallback( (name: string) => { const cur = manifestLibs ?? []; const next = cur.filter((l) => normLib(l) !== normLib(name)); setLibraries(next.length ? next : null); }, [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 || '{}'); const libs = Array.isArray(parsed) ? parsed : parsed.libraries; if (!Array.isArray(libs) || !libs.every((x) => typeof x === 'string')) { setJsonError('Expected {"libraries": ["Name", ...]}'); return; } setJsonError(null); setLibraries(libs.length ? (libs as string[]) : null); } catch (e) { setJsonError(e instanceof Error ? e.message : 'Invalid JSON'); } }, [jsonDraft, setLibraries]); // Open the Project (velxio.json) tab when launched from the explorer entry. useEffect(() => { const toProject = () => setActiveTab('project'); window.addEventListener('velxio-open-library-manager', toProject); return () => window.removeEventListener('velxio-open-library-manager', toProject); }, []); // P2.2 — a custom .zip upload lands in the user's PER-USER store (not the // shared dir), so auto-declare it on the active board (velxio.json) and show // the Project tab; the compile then resolves it via the owner per-user path. useEffect(() => { const onUploaded = (e: Event) => { const name = (e as CustomEvent).detail?.library; if (name) { addToManifest(name); setActiveTab('project'); } fetchInstalled(); }; window.addEventListener('velxio-custom-library-installed', onUploaded); return () => window.removeEventListener('velxio-custom-library-installed', onUploaded); }, [addToManifest, fetchInstalled]); // Keep the raw velxio.json draft in sync with the manifest while not editing. useEffect(() => { setJsonDraft(JSON.stringify({ libraries: manifestLibs ?? [] }, null, 2)); setJsonError(null); }, [manifestLibs, isOpen]); const handleClose = () => { onClose(); }; if (!isOpen) return null; const isInstalled = (libName: string): boolean => installedLibraries.some( (il) => (il.library?.name || il.name || '').toLowerCase() === libName.toLowerCase(), ); const getLibName = (lib: ArduinoLibrary): string => lib.name || 'Unknown'; const getLibVersion = (lib: ArduinoLibrary): string => lib.latest?.version || lib.version || ''; const getLibAuthor = (lib: ArduinoLibrary): string => lib.latest?.author || lib.author || ''; const getLibDesc = (lib: ArduinoLibrary): string => lib.latest?.sentence || lib.sentence || ''; const getInstalledName = (lib: InstalledLibrary): string => lib.library?.name || lib.name || 'Unknown'; const getInstalledVersion = (lib: InstalledLibrary): string => lib.library?.version || lib.version || ''; const getInstalledAuthor = (lib: InstalledLibrary): string => lib.library?.author || lib.author || ''; const getInstalledDesc = (lib: InstalledLibrary): string => lib.library?.sentence || lib.sentence || ''; return (
e.stopPropagation()}> {/* Header */}
{t('editor.libraryManager.title')}
{/* Tabs */}
{/* Status bar */} {statusMsg && (
{statusMsg.type === 'success' ? ( ) : ( )} {statusMsg.text}
)} {/* Project Tab — velxio.json: the libraries THIS project declares. These (plus the arduino-esp32 core) are the ESP32 compile scope, so the project never picks up another project's or user's libraries. */} {activeTab === 'project' && (
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 */}
{declared.length === 0 && (

No libraries declared.

Core libraries (WiFi, Wire, SPI, WebServer…) are always available. Add external libraries from the Search tab or below.

)} {declared.map((name, i) => (
{name}
))}
{/* 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 */}
Edit velxio.json directly