import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; 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 { 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 ?? []; 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]); // 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 { setSearchResults(await searchLibraries(searchQuery)); } catch (e: unknown) { setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Search failed' }); setSearchResults([]); } finally { setLoadingSearch(false); } }, 400); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [searchQuery, isOpen]); // 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]); if (!isOpen) return null; const browsing = !searchQuery.trim(); return (
e.stopPropagation()}> {/* Header — the pro custom-upload button injects into .lib-modal-header */}
{t('editor.libraryManager.title')} {activeBoard && ( {boardDisplayName(activeBoard)} · {declared.length} )}
{/* Search */}
setSearchQuery(e.target.value)} autoFocus /> {loadingSearch && ( )}
{/* Status */} {statusMsg &&
{statusMsg.text}
} {/* Single unified list */}
{browsing && loadingInstalled && (

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

)} {!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.

}
)} {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 && ( )}
); })}
); };