Merge pull request #135 from ZhadowValker/feature/library-version-uninstall
feat: Add library version management and uninstall functionality
This commit is contained in:
commit
b1026ec77e
|
|
@ -6,6 +6,7 @@ router = APIRouter()
|
||||||
|
|
||||||
class InstallLibraryRequest(BaseModel):
|
class InstallLibraryRequest(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
version: str | None = None
|
||||||
|
|
||||||
class SearchResponse(BaseModel):
|
class SearchResponse(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
|
|
@ -16,6 +17,8 @@ class InstallResponse(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
stdout: str | None = None
|
stdout: str | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
fallback: bool | None = None
|
||||||
|
requested_version: str | None = None
|
||||||
|
|
||||||
@router.get("/search", response_model=SearchResponse)
|
@router.get("/search", response_model=SearchResponse)
|
||||||
async def search_libraries(q: str = Query(..., description="Search query for library")):
|
async def search_libraries(q: str = Query(..., description="Search query for library")):
|
||||||
|
|
@ -38,10 +41,32 @@ async def install_library(request: InstallLibraryRequest):
|
||||||
Install a specific Arduino library by name.
|
Install a specific Arduino library by name.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await arduino_cli.install_library(request.name)
|
spec = f"{request.name}@{request.version}" if request.version else request.name
|
||||||
|
result = await arduino_cli.install_library(spec)
|
||||||
if not result["success"]:
|
if not result["success"]:
|
||||||
return InstallResponse(success=False, error=result.get("error"), stdout=result.get("stdout"))
|
return InstallResponse(success=False, error=result.get("error"), stdout=result.get("stdout"))
|
||||||
return InstallResponse(success=True, stdout=result.get("stdout"))
|
return InstallResponse(success=True, stdout=result.get("stdout"), fallback=result.get("fallback"), requested_version=result.get("requested_version"))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
class UninstallLibraryRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
class UninstallResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
stdout: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
@router.delete("/uninstall", response_model=UninstallResponse)
|
||||||
|
async def uninstall_library(request: UninstallLibraryRequest):
|
||||||
|
"""
|
||||||
|
Uninstall a specific Arduino library by name.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await arduino_cli.uninstall_library(request.name)
|
||||||
|
if not result["success"]:
|
||||||
|
return UninstallResponse(success=False, error=result.get("error"), stdout=result.get("stdout"))
|
||||||
|
return UninstallResponse(success=True, stdout=result.get("stdout"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -516,7 +516,11 @@ class ArduinoCLIService:
|
||||||
# frontend can access lib.latest.version / author / sentence directly.
|
# frontend can access lib.latest.version / author / sentence directly.
|
||||||
def _parse_version(v: str):
|
def _parse_version(v: str):
|
||||||
try:
|
try:
|
||||||
return tuple(int(x) for x in v.split("."))
|
parts = v.split(".")
|
||||||
|
# Reject if any part is not a digit (filters out "1_2_3", "beta", "latest")
|
||||||
|
if any(not p.isdigit() for p in parts):
|
||||||
|
return (0,)
|
||||||
|
return tuple(int(p) for p in parts)
|
||||||
except Exception:
|
except Exception:
|
||||||
return (0,)
|
return (0,)
|
||||||
|
|
||||||
|
|
@ -539,16 +543,41 @@ class ArduinoCLIService:
|
||||||
Install an Arduino library.
|
Install an Arduino library.
|
||||||
Handles standard library names as well as Wokwi-hosted entries in
|
Handles standard library names as well as Wokwi-hosted entries in
|
||||||
the form "LibName@wokwi:projectHash".
|
the form "LibName@wokwi:projectHash".
|
||||||
|
Also handles versioned installs via "LibName@version" syntax
|
||||||
|
(e.g. "Adafruit NeoPixel@1.11.0").
|
||||||
|
|
||||||
|
@latest is stripped — arduino-cli does not support it.
|
||||||
|
Malformed version strings (non-semver) fall back to plain name install.
|
||||||
"""
|
"""
|
||||||
if '@wokwi:' in library_name:
|
if '@wokwi:' in library_name:
|
||||||
return await self._install_wokwi_library(library_name)
|
return await self._install_wokwi_library(library_name)
|
||||||
|
|
||||||
|
# Strip @latest — arduino-cli does not support this token
|
||||||
|
if library_name.endswith('@latest'):
|
||||||
|
library_name = library_name[:-7]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"Installing library: {library_name}")
|
print(f"Installing library: {library_name}")
|
||||||
|
|
||||||
|
# Handle "Name@version" syntax for versioned installs
|
||||||
|
# Only quote if the version part is valid semver (major.minor.patch)
|
||||||
|
import re
|
||||||
|
lib_spec = library_name
|
||||||
|
if '@' in library_name:
|
||||||
|
parts = library_name.rsplit('@', 1)
|
||||||
|
if len(parts) == 2 and parts[1]:
|
||||||
|
version = parts[1]
|
||||||
|
# Validate semver: major.minor.patch (all numeric)
|
||||||
|
if re.fullmatch(r'\d+\.\d+\.\d+', version):
|
||||||
|
lib_spec = library_name # no quotes needed — subprocess passes args literally
|
||||||
|
else:
|
||||||
|
# Bad/empty version — fall back to plain name
|
||||||
|
library_name = parts[0]
|
||||||
|
lib_spec = library_name
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
[self.cli_path, "lib", "install", library_name],
|
[self.cli_path, "lib", "install", lib_spec],
|
||||||
capture_output=True, text=True, encoding='utf-8', errors='replace'
|
capture_output=True, text=True, encoding='utf-8', errors='replace'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -558,6 +587,27 @@ class ArduinoCLIService:
|
||||||
print(f"Successfully installed {library_name}")
|
print(f"Successfully installed {library_name}")
|
||||||
return {"success": True, "stdout": result.stdout}
|
return {"success": True, "stdout": result.stdout}
|
||||||
else:
|
else:
|
||||||
|
# If a specific version failed, retry with plain name (latest) in case
|
||||||
|
# the version string is valid semver but rejected by arduino-cli for
|
||||||
|
# other reasons (e.g. leading zeros, lib index corruption).
|
||||||
|
if '@' in library_name:
|
||||||
|
plain_name = library_name.rsplit('@', 1)[0]
|
||||||
|
version = library_name.rsplit('@', 1)[1]
|
||||||
|
print(f"Versioned install failed, retrying with plain name: {plain_name}")
|
||||||
|
def _run_plain():
|
||||||
|
return subprocess.run(
|
||||||
|
[self.cli_path, "lib", "install", plain_name],
|
||||||
|
capture_output=True, text=True, encoding='utf-8', errors='replace'
|
||||||
|
)
|
||||||
|
result = await asyncio.to_thread(_run_plain)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f"Successfully installed {plain_name} (fallback to latest)")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"stdout": result.stdout,
|
||||||
|
"fallback": True,
|
||||||
|
"requested_version": version,
|
||||||
|
}
|
||||||
print(f"Failed to install {library_name}: {result.stderr}")
|
print(f"Failed to install {library_name}: {result.stderr}")
|
||||||
return {"success": False, "error": result.stderr, "stdout": result.stdout}
|
return {"success": False, "error": result.stderr, "stdout": result.stdout}
|
||||||
|
|
||||||
|
|
@ -719,3 +769,29 @@ class ArduinoCLIService:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Exception listing libraries: {e}")
|
print(f"Exception listing libraries: {e}")
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
async def uninstall_library(self, library_name: str) -> dict:
|
||||||
|
"""
|
||||||
|
Uninstall an Arduino library.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print(f"Uninstalling library: {library_name}")
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
return subprocess.run(
|
||||||
|
[self.cli_path, "lib", "uninstall", library_name],
|
||||||
|
capture_output=True, text=True, encoding='utf-8', errors='replace'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await asyncio.to_thread(_run)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f"Successfully uninstalled {library_name}")
|
||||||
|
return {"success": True, "stdout": result.stdout}
|
||||||
|
else:
|
||||||
|
print(f"Failed to uninstall {library_name}: {result.stderr}")
|
||||||
|
return {"success": False, "error": result.stderr, "stdout": result.stdout}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Exception uninstalling library: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,33 @@ interface InstallLibrariesModalProps {
|
||||||
type ItemStatus = 'pending' | 'installing' | 'done' | 'error';
|
type ItemStatus = 'pending' | 'installing' | 'done' | 'error';
|
||||||
|
|
||||||
interface LibItem {
|
interface LibItem {
|
||||||
|
/** Full spec as read from libraries.txt — may contain "@version" suffix */
|
||||||
|
spec: string;
|
||||||
|
/** Parsed library name (without @version or @wokwi:hash) */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Version if present and valid semver, otherwise undefined */
|
||||||
|
version?: string;
|
||||||
status: ItemStatus;
|
status: ItemStatus;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Split "LibName@version" into { name, version }.
|
||||||
|
* Returns version=undefined if no valid semver suffix.
|
||||||
|
* Handles wokwi-hosted "LibName@wokwi:hash" — version stays undefined. */
|
||||||
|
function parseLibSpec(spec: string): { name: string; version?: string } {
|
||||||
|
if (spec.includes('@wokwi:')) {
|
||||||
|
return { name: spec.split('@wokwi:')[0] };
|
||||||
|
}
|
||||||
|
const idx = spec.lastIndexOf('@');
|
||||||
|
if (idx > 0) {
|
||||||
|
const ver = spec.slice(idx + 1);
|
||||||
|
if (/^\d+\.\d+\.\d+$/.test(ver)) {
|
||||||
|
return { name: spec.slice(0, idx), version: ver };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { name: spec };
|
||||||
|
}
|
||||||
|
|
||||||
const Spinner: React.FC<{ size?: number }> = ({ size = 16 }) => (
|
const Spinner: React.FC<{ size?: number }> = ({ size = 16 }) => (
|
||||||
<svg
|
<svg
|
||||||
className="ilib-spinner"
|
className="ilib-spinner"
|
||||||
|
|
@ -37,40 +59,48 @@ export const InstallLibrariesModal: React.FC<InstallLibrariesModalProps> = ({
|
||||||
libraries,
|
libraries,
|
||||||
}) => {
|
}) => {
|
||||||
const [items, setItems] = useState<LibItem[]>(() =>
|
const [items, setItems] = useState<LibItem[]>(() =>
|
||||||
libraries.map((name) => ({ name, status: 'pending' })),
|
libraries.map((spec) => {
|
||||||
|
const { name, version } = parseLibSpec(spec);
|
||||||
|
return { spec, name, version, status: 'pending' as ItemStatus };
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [doneCount, setDoneCount] = useState(0);
|
const [doneCount, setDoneCount] = useState(0);
|
||||||
|
|
||||||
// Sync items when the libraries prop changes (new import)
|
// Sync items when the libraries prop changes (new import)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setItems(libraries.map((name) => ({ name, status: 'pending' })));
|
setItems(libraries.map((spec) => {
|
||||||
|
const { name, version } = parseLibSpec(spec);
|
||||||
|
return { spec, name, version, status: 'pending' as ItemStatus };
|
||||||
|
}));
|
||||||
setDoneCount(0);
|
setDoneCount(0);
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
}, [libraries]);
|
}, [libraries]);
|
||||||
|
|
||||||
const setItemStatus = useCallback((name: string, status: ItemStatus, error?: string) => {
|
const setItemStatus = useCallback(
|
||||||
setItems((prev) => prev.map((it) => (it.name === name ? { ...it, status, error } : it)));
|
(spec: string, status: ItemStatus, error?: string) => {
|
||||||
}, []);
|
setItems((prev) =>
|
||||||
|
prev.map((it) => (it.spec === spec ? { ...it, status, error } : it)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleInstallAll = useCallback(async () => {
|
const handleInstallAll = useCallback(async () => {
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.status === 'done') {
|
if (item.status === 'done') { completed++; continue; }
|
||||||
completed++;
|
setItemStatus(item.spec, 'installing');
|
||||||
continue;
|
|
||||||
}
|
|
||||||
setItemStatus(item.name, 'installing');
|
|
||||||
try {
|
try {
|
||||||
const result = await installLibrary(item.name);
|
const result = await installLibrary(item.spec);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setItemStatus(item.name, 'done');
|
setItemStatus(item.spec, 'done');
|
||||||
} else {
|
} else {
|
||||||
setItemStatus(item.name, 'error', result.error || 'Install failed');
|
setItemStatus(item.spec, 'error', result.error || 'Install failed');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setItemStatus(item.name, 'error', e instanceof Error ? e.message : 'Install failed');
|
setItemStatus(item.spec, 'error', e instanceof Error ? e.message : 'Install failed');
|
||||||
}
|
}
|
||||||
completed++;
|
completed++;
|
||||||
setDoneCount(completed);
|
setDoneCount(completed);
|
||||||
|
|
@ -142,15 +172,14 @@ export const InstallLibrariesModal: React.FC<InstallLibrariesModalProps> = ({
|
||||||
{/* Library list */}
|
{/* Library list */}
|
||||||
<div className="ilib-list">
|
<div className="ilib-list">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
// For Wokwi-hosted libraries ("LibName@wokwi:hash"), show only the LibName
|
const isWokwiLib = item.spec.includes('@wokwi:');
|
||||||
const displayName = item.name.includes('@wokwi:')
|
|
||||||
? item.name.split('@wokwi:')[0]
|
|
||||||
: item.name;
|
|
||||||
const isWokwiLib = item.name.includes('@wokwi:');
|
|
||||||
return (
|
return (
|
||||||
<div key={item.name} className={`ilib-item ilib-item--${item.status}`}>
|
<div key={item.spec} className={`ilib-item ilib-item--${item.status}`}>
|
||||||
<span className="ilib-item-name">
|
<span className="ilib-item-name">
|
||||||
{displayName}
|
{item.name}
|
||||||
|
{item.version && (
|
||||||
|
<span className="ilib-version">v{item.version}</span>
|
||||||
|
)}
|
||||||
{isWokwiLib && (
|
{isWokwiLib && (
|
||||||
<span className="ilib-badge ilib-badge--wokwi" title="Wokwi-hosted library">
|
<span className="ilib-badge ilib-badge--wokwi" title="Wokwi-hosted library">
|
||||||
wokwi
|
wokwi
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
searchLibraries,
|
searchLibraries,
|
||||||
installLibrary,
|
installLibrary,
|
||||||
getInstalledLibraries,
|
getInstalledLibraries,
|
||||||
|
uninstallLibrary,
|
||||||
} from '../../services/libraryService';
|
} from '../../services/libraryService';
|
||||||
import type { ArduinoLibrary, InstalledLibrary } from '../../services/libraryService';
|
import type { ArduinoLibrary, InstalledLibrary } from '../../services/libraryService';
|
||||||
import { trackInstallLibrary } from '../../utils/analytics';
|
import { trackInstallLibrary } from '../../utils/analytics';
|
||||||
|
|
@ -23,6 +24,9 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
||||||
const [loadingSearch, setLoadingSearch] = useState(false);
|
const [loadingSearch, setLoadingSearch] = useState(false);
|
||||||
const [loadingInstalled, setLoadingInstalled] = useState(false);
|
const [loadingInstalled, setLoadingInstalled] = useState(false);
|
||||||
const [installingLib, setInstallingLib] = useState<string | null>(null);
|
const [installingLib, setInstallingLib] = useState<string | null>(null);
|
||||||
|
const [uninstallingLib, setUninstallingLib] = useState<string | null>(null);
|
||||||
|
/** Track user-selected version per library name */
|
||||||
|
const [selectedVersions, setSelectedVersions] = useState<Record<string, string>>({});
|
||||||
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(
|
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
@ -89,11 +93,16 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
||||||
setInstallingLib(libName);
|
setInstallingLib(libName);
|
||||||
setStatusMsg(null);
|
setStatusMsg(null);
|
||||||
try {
|
try {
|
||||||
const result = await installLibrary(libName);
|
const version = selectedVersions[libName];
|
||||||
|
const result = await installLibrary(libName, version);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
trackInstallLibrary(libName);
|
trackInstallLibrary(libName);
|
||||||
setStatusMsg({ type: 'success', text: `"${libName}" installed successfully!` });
|
if (result.fallback) {
|
||||||
fetchInstalled(); // Refresh installed list so search tab reflects new state
|
setStatusMsg({ type: 'success', text: `"${libName}" installed (latest — requested @${result.requested_version} was not available)` });
|
||||||
|
} else {
|
||||||
|
setStatusMsg({ type: 'success', text: `"${libName}${version ? ' @' + version : ''}" installed successfully!` });
|
||||||
|
}
|
||||||
|
fetchInstalled();
|
||||||
} else {
|
} else {
|
||||||
setStatusMsg({ type: 'error', text: result.error || `Failed to install "${libName}"` });
|
setStatusMsg({ type: 'error', text: result.error || `Failed to install "${libName}"` });
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +113,24 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
@ -296,42 +323,45 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
||||||
{getLibDesc(lib) && <p className="lib-item-desc">{getLibDesc(lib)}</p>}
|
{getLibDesc(lib) && <p className="lib-item-desc">{getLibDesc(lib)}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="lib-item-actions">
|
<div className="lib-item-actions">
|
||||||
{getLibVersion(lib) && (
|
|
||||||
<span className="lib-item-version">{getLibVersion(lib)}</span>
|
|
||||||
)}
|
|
||||||
{isInstalled(getLibName(lib)) ? (
|
{isInstalled(getLibName(lib)) ? (
|
||||||
<span className="lib-item-version lib-installed-badge">
|
<>
|
||||||
INSTALLED
|
<span className="lib-item-version lib-installed-badge">
|
||||||
<svg
|
{selectedVersions[lib.name] ?? lib.latest?.version ?? ''}
|
||||||
style={{
|
<svg style={{ display: 'inline', marginLeft: '4px', verticalAlign: 'middle' }} width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
|
||||||
display: 'inline',
|
</span>
|
||||||
marginLeft: '4px',
|
<button
|
||||||
verticalAlign: 'middle',
|
className="lib-uninstall-btn"
|
||||||
}}
|
onClick={() => handleUninstall(getLibName(lib))}
|
||||||
width="12"
|
disabled={uninstallingLib !== null}
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
>
|
||||||
<polyline points="20 6 9 17 4 12" />
|
{uninstallingLib === getLibName(lib) ? 'Uninstalling...' : 'UNINSTALL'}
|
||||||
</svg>
|
</button>
|
||||||
</span>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<>
|
||||||
className="lib-install-btn"
|
{lib.releases && Object.keys(lib.releases).length > 1 && (
|
||||||
onClick={() => handleInstall(getLibName(lib))}
|
<select
|
||||||
disabled={installingLib !== null}
|
className="lib-version-select"
|
||||||
>
|
value={selectedVersions[lib.name] ?? lib.latest?.version ?? ''}
|
||||||
{installingLib === getLibName(lib) ? (
|
onChange={(e) => setSelectedVersions((prev) => ({ ...prev, [lib.name]: e.target.value }))}
|
||||||
<span className="lib-installing">Installing...</span>
|
>
|
||||||
) : (
|
{Object.entries(lib.releases).map(([ver]) => (
|
||||||
'INSTALL'
|
<option key={ver} value={ver}>{ver}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
)}
|
)}
|
||||||
</button>
|
<button
|
||||||
|
className="lib-install-btn"
|
||||||
|
onClick={() => handleInstall(getLibName(lib))}
|
||||||
|
disabled={installingLib !== null}
|
||||||
|
>
|
||||||
|
{installingLib === getLibName(lib) ? (
|
||||||
|
<span className="lib-installing">Installing...</span>
|
||||||
|
) : (
|
||||||
|
'INSTALL'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -387,6 +417,13 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
className="lib-uninstall-btn"
|
||||||
|
onClick={() => handleUninstall(getInstalledName(lib))}
|
||||||
|
disabled={uninstallingLib !== null}
|
||||||
|
>
|
||||||
|
{uninstallingLib === getInstalledName(lib) ? 'Uninstalling...' : 'UNINSTALL'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,20 @@ export async function searchLibraries(query: string): Promise<ArduinoLibrary[]>
|
||||||
return data.libraries || [];
|
return data.libraries || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installLibrary(name: string): Promise<{ success: boolean; error?: string }> {
|
export async function installLibrary(name: string, version?: string): Promise<{ success: boolean; error?: string; fallback?: boolean; requested_version?: string }> {
|
||||||
const res = await fetch(`${API_BASE}/install`, {
|
const res = await fetch(`${API_BASE}/install`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, version: version ?? null }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uninstallLibrary(name: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const res = await fetch(`${API_BASE}/uninstall`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -62,3 +72,30 @@ export async function getInstalledLibraries(): Promise<InstalledLibrary[]> {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.libraries || [];
|
return data.libraries || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a library name to "Name@X.Y.Z" using arduino-cli lib search.
|
||||||
|
* Returns null if the library is not found in the index.
|
||||||
|
* Used during export to resolve versions for libraries that are not locally installed.
|
||||||
|
*/
|
||||||
|
export async function resolveLibraryVersion(libName: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const results = await searchLibraries(libName);
|
||||||
|
// Find best match: exact name or name without underscores/spaces
|
||||||
|
const normalised = libName.replace(/[\s_]+/g, '').toLowerCase();
|
||||||
|
const match = results.find(
|
||||||
|
(r) =>
|
||||||
|
r.name.replace(/[\s_]+/g, '').toLowerCase() === normalised ||
|
||||||
|
normalised.includes(r.name.replace(/[\s_]+/g, '').toLowerCase()) ||
|
||||||
|
r.name.replace(/[\s_]+/g, '').toLowerCase().includes(normalised),
|
||||||
|
);
|
||||||
|
if (!match) return null;
|
||||||
|
const latest = match.latest?.version;
|
||||||
|
if (latest && /^\d+\.\d+\.\d+$/.test(latest)) {
|
||||||
|
return `${match.name}@${latest}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue