import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { searchLibraries, installLibrary, getInstalledLibraries, getCustomLibraries, deleteCustomLibrary, } from '../../services/libraryService'; import type { ArduinoLibrary, InstalledLibrary } from '../../services/libraryService'; import { trackInstallLibrary } from '../../utils/analytics'; import { BUILTIN_MPY_MODULES, FEATURED_MPY_PACKAGES, fetchMpyPackage, packageFilesInGroup, searchMpyPackages, writeFilesIntoGroup, } from '../../services/micropythonLibs'; import type { MpyPackage } from '../../services/micropythonLibs'; import { useSimulatorStore } from '../../store/useSimulatorStore'; import { boardDisplayName } from '../../types/board'; import './LibraryManagerModal.css'; interface LibraryManagerModalProps { isOpen: boolean; onClose: () => void; } /** Case/separator-insensitive name match, matching the backend's _norm_lib_name * 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, ''); /** One row of the single unified list. A search result and an installed/custom * library both normalise to this so they render identically. */ interface LibRow { name: string; version: string; author: string; desc: string; installed: boolean; custom: boolean; releases?: Record; } /** * Library Manager — ONE list, no tabs. Each row is state-aware: * + Add to project (installs if needed, then declares it on this board) * In project ✓ (click to remove from this board's libraries.json) * Uninstall / Remove (free the cache / remove your custom upload) * * The per-board manifest (board.libraries) IS the compile scope and is what the * read-only `libraries.json` file in the explorer shows. This modal is the only * place that edits it. */ export const LibraryManagerModal: React.FC = ({ isOpen, onClose }) => { const { t } = useTranslation(); // Per-board manifest: the libraries.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 declared = manifestLibs ?? []; // ── MicroPython mode (issue #214) ───────────────────────────────────────── // Same modal, different backing: a MicroPython library is a .py file in // the project, so search hits the micropython-lib index (backend proxy) // and "Add to project" writes the package's files into this board's file // group — no libraries.json, no arduino-cli. const isMpy = activeBoard?.languageMode === 'micropython'; const [mpyResults, setMpyResults] = useState([]); // Bumped after add/remove so the "In project" pills re-derive from the // file group (which lives outside React state). const [mpyTick, setMpyTick] = useState(0); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [installedLibraries, setInstalledLibraries] = useState([]); const [loadingSearch, setLoadingSearch] = useState(false); const [loadingInstalled, setLoadingInstalled] = useState(false); const [busyLib, setBusyLib] = useState(null); // install/uninstall in flight const [selectedVersions, setSelectedVersions] = useState>({}); const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>( null, ); const debounceRef = useRef | null>(null); const setLibraries = useCallback( (libs: string[] | null) => { if (!activeBoard) return; updateBoard(activeBoard.id, { libraries: libs && libs.length ? libs : undefined }); }, [activeBoard, updateBoard], ); 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], ); const isInstalled = useCallback( (name: string): boolean => installedLibraries.some((il) => normLib(il.library?.name || il.name || '') === normLib(name)), [installedLibraries], ); const fetchInstalled = useCallback(async () => { setLoadingInstalled(true); try { // The shared index libs PLUS the user's per-user custom uploads (which // live in their per-user store). Custom first; de-duped by name. const [libs, custom] = await Promise.all([getInstalledLibraries(), getCustomLibraries()]); const customNames = new Set(custom.map((c) => (c.name || '').toLowerCase())); setInstalledLibraries([ ...custom, ...libs.filter((l) => !customNames.has((l.library?.name || l.name || '').toLowerCase())), ]); } catch (e: unknown) { setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Failed to load installed libraries', }); } finally { setLoadingInstalled(false); } }, []); // Reset transient state when the modal closes. useEffect(() => { if (!isOpen) { setSearchQuery(''); setSearchResults([]); setStatusMsg(null); } }, [isOpen]); // Load the installed/custom list whenever the modal opens. useEffect(() => { if (isOpen) fetchInstalled(); }, [isOpen, fetchInstalled]); // Escape closes it, like any dialog. The overlay already closes on a // backdrop click, but the toolbar button that OPENED this sits underneath // the overlay — so someone who installs a library and reaches for it to get // back to their sketch clicks a button that cannot be hit, and the editor // reads as frozen. Escape is the exit everyone tries next. useEffect(() => { if (!isOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [isOpen, onClose]); // Search the index (debounced). With an empty query we BROWSE the installed // list instead, so don't fire a search. useEffect(() => { if (!isOpen) return; if (debounceRef.current) clearTimeout(debounceRef.current); if (!searchQuery.trim()) { setSearchResults([]); setLoadingSearch(false); return; } debounceRef.current = setTimeout(async () => { setLoadingSearch(true); setStatusMsg(null); try { if (isMpy) setMpyResults(await searchMpyPackages(searchQuery)); else setSearchResults(await searchLibraries(searchQuery)); } catch (e: unknown) { setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Search failed' }); if (isMpy) setMpyResults([]); else setSearchResults([]); } finally { setLoadingSearch(false); } }, 400); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [searchQuery, isOpen, isMpy]); // A custom .zip upload (pro) lands in the user's per-user store; auto-declare // it on the active board and refresh the list. The upload BUTTON is injected // into .lib-modal-header by the pro overlay (libraryUploadInjector). useEffect(() => { const onUploaded = (e: Event) => { const name = (e as CustomEvent).detail?.library; if (name) addToManifest(name); fetchInstalled(); }; window.addEventListener('velxio-custom-library-installed', onUploaded); return () => window.removeEventListener('velxio-custom-library-installed', onUploaded); }, [addToManifest, fetchInstalled]); // ── actions ──────────────────────────────────────────────────────────────── const install = useCallback( async (name: string): Promise => { setBusyLib(name); setStatusMsg(null); try { const result = await installLibrary(name, selectedVersions[name]); if (result.success) { trackInstallLibrary(name); fetchInstalled(); return true; } setStatusMsg({ type: 'error', text: result.error || `Failed to install "${name}"` }); return false; } catch (e: unknown) { setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Installation failed' }); return false; } finally { setBusyLib(null); } }, [selectedVersions, fetchInstalled], ); // Primary action: install if needed, then declare on THIS board. const addToProject = useCallback( async (row: LibRow) => { if (!row.installed) { const ok = await install(row.name); if (!ok) return; } addToManifest(row.name); setStatusMsg({ type: 'success', text: `"${row.name}" added to ${activeBoard ? boardDisplayName(activeBoard) : 'this board'}.` }); }, [install, addToManifest, activeBoard], ); // A CUSTOM lib lives in the user's per-user store, so removing it hits the // per-user delete endpoint and also drops it from the manifest. const removeCustom = useCallback( async (name: string) => { setBusyLib(name); setStatusMsg(null); try { const result = await deleteCustomLibrary(name); if (result.success) { setStatusMsg({ type: 'success', text: `Removed your custom "${name}".` }); removeFromManifest(name); fetchInstalled(); } else { setStatusMsg({ type: 'error', text: result.error || `Failed to remove "${name}"` }); } } finally { setBusyLib(null); } }, [removeFromManifest, fetchInstalled], ); // ── unified rows: search results when typing, else the installed/custom list ─ const rows: LibRow[] = useMemo(() => { if (searchQuery.trim()) { return searchResults.map((lib) => ({ name: lib.name || 'Unknown', version: lib.latest?.version || lib.version || '', author: lib.latest?.author || lib.author || '', desc: lib.latest?.sentence || lib.sentence || '', installed: isInstalled(lib.name || ''), custom: false, releases: lib.releases, })); } return installedLibraries.map((lib) => ({ name: lib.library?.name || lib.name || 'Unknown', version: lib.library?.version || lib.version || '', author: lib.library?.author || lib.author || '', desc: lib.library?.sentence || lib.sentence || '', installed: true, custom: !!lib.custom, })); }, [searchQuery, searchResults, installedLibraries, isInstalled]); // ── MicroPython actions ─────────────────────────────────────────────────── const mpyGroupId = activeBoard?.activeFileGroupId ?? null; const addMpyPackage = useCallback( async (name: string) => { if (!mpyGroupId) return; setBusyLib(name); setStatusMsg(null); try { const files = await fetchMpyPackage(name); const written = writeFilesIntoGroup(mpyGroupId, files); trackInstallLibrary(`mpy:${name}`); setStatusMsg({ type: 'success', text: `"${name}" added to the workspace: ${written.join(', ')}. main.py can import it now.`, }); setMpyTick((n) => n + 1); } catch (e: unknown) { const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail; setStatusMsg({ type: 'error', text: detail || (e instanceof Error ? e.message : `Failed to add "${name}"`), }); } finally { setBusyLib(null); } }, [mpyGroupId], ); interface MpyRow { name: string; version: string; desc: string; builtin: boolean; inProject: boolean; } // eslint-disable-next-line react-hooks/exhaustive-deps -- mpyTick re-derives from the file group const mpyRows: MpyRow[] = useMemo(() => { if (!isMpy || !mpyGroupId) return []; const inProj = (name: string) => packageFilesInGroup(mpyGroupId, name); if (searchQuery.trim()) { const needle = searchQuery.trim().toLowerCase(); const builtins = BUILTIN_MPY_MODULES.filter((b) => b.name.includes(needle)).map((b) => ({ name: b.name, version: '', desc: b.blurb, builtin: true, inProject: false, })); return builtins.concat( mpyResults.map((p) => ({ name: p.name, version: p.version, desc: p.description, builtin: false, inProject: inProj(p.name), })), ); } return FEATURED_MPY_PACKAGES.map((f) => ({ name: f.name, version: '', desc: f.blurb, builtin: false, inProject: inProj(f.name), })).concat( BUILTIN_MPY_MODULES.map((b) => ({ name: b.name, version: '', desc: b.blurb, builtin: true, inProject: false, })), ); }, [isMpy, mpyGroupId, searchQuery, mpyResults, mpyTick]); if (!isOpen) return null; const browsing = !searchQuery.trim(); // Portaled to : the toolbar ancestor has container-type // (container queries), which turns it into the containing block for // position:fixed and shrank the overlay to the toolbar's box. return createPortal(
e.stopPropagation()}> {/* Header — the pro custom-upload button injects into .lib-modal-header */}
{t('editor.libraryManager.title')} {activeBoard && ( {boardDisplayName(activeBoard)} {isMpy ? ' · MicroPython' : ` · ${declared.length}`} )}
{/* Search */}
setSearchQuery(e.target.value)} autoFocus /> {loadingSearch && ( )}
{/* Status */} {statusMsg &&
{statusMsg.text}
} {/* Single unified list */}
{isMpy && browsing && (

A MicroPython library is a .py file in the project — adding one writes it into this board's workspace, next to main.py. Search the official micropython-lib index above.

)} {isMpy && mpyRows.map((row) => { const busy = busyLib === row.name; return (
{row.name} {row.builtin && ( built into the firmware )}
{row.desc &&

{row.desc}

}
{row.version && {row.version}} {row.builtin ? ( import {row.name} ) : row.inProject ? ( In project ✓ ) : ( )}
); })} {isMpy && !browsing && !loadingSearch && mpyRows.length === 0 && (

{t('editor.libraryManager.noResultsFor', { query: searchQuery })}

Not in micropython-lib? Paste the driver as a .py file in the workspace — that is all an install does.

)} {!isMpy && browsing && loadingInstalled && (

{t('editor.libraryManager.loadingInstalled')}

)} {!isMpy && !loadingSearch && rows.length === 0 && !(browsing && loadingInstalled) && (

{browsing ? t('editor.libraryManager.noneInstalled') : searchQuery ? t('editor.libraryManager.noResultsFor', { query: searchQuery }) : t('editor.libraryManager.noResults')}

{browsing &&

Search above to find and add a library.

}
)} {!isMpy && rows.map((row, i) => { const inProj = inManifest(row.name); const busy = busyLib === row.name; return (
{row.name} {row.custom && ( custom )} {row.author && ( {t('editor.libraryManager.byAuthor', { author: row.custom ? 'you' : row.author })} )}
{row.desc &&

{row.desc}

}
{row.version && {row.version}} {!row.installed && row.releases && Object.keys(row.releases).length > 1 && ( )} {inProj ? ( ) : ( )} {/* Only CUSTOM uploads can be removed (per-user store). Index libraries live in the shared content-addressed cache — you add/remove them from THIS project, but never "uninstall" a copy everyone shares, so no Uninstall button for them. */} {row.custom && ( )}
); })}
, document.body, ); };