From 02c8ad756dd6caabb08bc4907c577979bbea68ad Mon Sep 17 00:00:00 2001 From: David Montero Date: Mon, 8 Jun 2026 15:51:34 +0200 Subject: [PATCH] feat(library-manager): collapse to a single unified tab with state-aware row actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the 3 tabs (In project / Search / Installed). One list now: browse your installed + custom libraries by default, search the index when you type. Each row is state-aware: + Add to project — installs if needed, then declares it on the active board In project (toggle) — click to remove from this board's manifest Uninstall / Remove — free the cache / remove your custom upload 'Install' is folded into 'Add to project' (install-on-add) for simplicity. The per-board manifest (board.libraries) stays the compile scope. The pro custom-zip upload button still injects into .lib-modal-header. The in-modal velxio.json editor tab is gone (the manifest is shown by the explorer's libraries.json file). --- .../simulator/LibraryManagerModal.tsx | 997 ++++++------------ 1 file changed, 331 insertions(+), 666 deletions(-) diff --git a/frontend/src/components/simulator/LibraryManagerModal.tsx b/frontend/src/components/simulator/LibraryManagerModal.tsx index 4dd3bf6e..5cd4277a 100644 --- a/frontend/src/components/simulator/LibraryManagerModal.tsx +++ b/frontend/src/components/simulator/LibraryManagerModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { searchLibraries, @@ -19,24 +19,56 @@ interface LibraryManagerModalProps { onClose: () => void; } -type Tab = 'project' | 'search' | 'installed'; +/** 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, ''); -/** 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, ''); +/** 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(); - 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. + + // 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; @@ -44,165 +76,11 @@ export const LibraryManagerModal: React.FC = ({ isOpen }, [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(); @@ -213,7 +91,6 @@ export const LibraryManagerModal: React.FC = ({ isOpen }, [manifestLibs, setLibraries], ); - const removeFromManifest = useCallback( (name: string) => { const cur = manifestLibs ?? []; @@ -223,119 +100,197 @@ export const LibraryManagerModal: React.FC = ({ isOpen [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]); + const isInstalled = useCallback( + (name: string): boolean => + installedLibraries.some((il) => normLib(il.library?.name || il.name || '') === normLib(name)), + [installedLibraries], + ); - // 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(() => { + const fetchInstalled = useCallback(async () => { + setLoadingInstalled(true); 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'); + // 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); } - }, [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. + // 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); - setActiveTab('project'); - } + if (name) addToManifest(name); 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]); + // ── 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], + ); - const handleClose = () => { - onClose(); - }; + // 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], + ); + + const uninstall = useCallback( + async (name: string) => { + setBusyLib(name); + setStatusMsg(null); + try { + const result = await uninstallLibrary(name); + if (result.success) { + setStatusMsg({ type: 'success', text: `"${name}" uninstalled.` }); + fetchInstalled(); + } else { + setStatusMsg({ type: 'error', text: result.error || `Failed to uninstall "${name}"` }); + } + } catch (e: unknown) { + setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Uninstall failed' }); + } finally { + setBusyLib(null); + } + }, + [fetchInstalled], + ); + + // 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 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 || ''; + const browsing = !searchQuery.trim(); return ( -
+
e.stopPropagation()}> - {/* Header */} + {/* Header — the pro custom-upload button injects into .lib-modal-header */}
= ({ isOpen {t('editor.libraryManager.title')} + {activeBoard && ( + + {boardDisplayName(activeBoard)} · {declared.length} + + )}
-
- {/* Tabs */} -
- - - + + + + setSearchQuery(e.target.value)} + autoFocus + /> + {loadingSearch && ( + + + + )}
- {/* Status bar */} - {statusMsg && ( -
- {statusMsg.type === 'success' ? ( - - - - ) : ( - - - - - )} - {statusMsg.text} -
- )} + {/* Status */} + {statusMsg &&
{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([]); - } - }} - /> + {/* Single unified list */} +
+
+ {browsing && loadingInstalled && ( +
+

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

- {addOptions.length > 0 && ( -
- {addOptions.map((opt) => ( - - ))} -
- )} -
- - {/* Raw velxio.json editor (Wokwi-style) for power users */} -
- - Edit velxio.json directly - -