/** * Plugin Manager Component * * Provides UI for managing installed plugins: * - List installed plugins with state indicators * - Install plugins from local paths * - Enable/disable plugins * - Configure plugin settings * - Uninstall plugins * - Live updates via SSE (plugin:lifecycle events) */ import { useState, useEffect, useCallback, useRef } from "react"; import { Package, Settings, Trash2, Plus, X, RefreshCw, RotateCcw, ExternalLink } from "lucide-react"; import { fetchPlugins, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin } from "../api"; import { DirectoryPicker } from "./DirectoryPicker"; import type { PluginInstallation, PluginState } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; /** SSE heartbeat watchdog timeout (matches useTasks hook) */ const SSE_HEARTBEAT_TIMEOUT_MS = 45_000; /** Normalized plugin lifecycle payload from SSE plugin:lifecycle events */ interface PluginLifecyclePayload { pluginId: string; transition: "installing" | "enabled" | "disabled" | "error" | "uninstalled" | "settings-updated"; sourceEvent: string; timestamp: string; projectId?: string; enabled: boolean; state: PluginState; version: string; settings: Record; error?: string; } interface PluginManagerProps { addToast: (message: string, type?: ToastType) => void; projectId?: string; } export const STATE_COLORS: Record = { started: "var(--color-success)", loaded: "var(--color-warning)", error: "var(--color-error)", stopped: "var(--color-muted)", installed: "var(--color-info)", }; export function PluginManager({ addToast, projectId }: PluginManagerProps) { const [plugins, setPlugins] = useState([]); const [loading, setLoading] = useState(true); const [showInstall, setShowInstall] = useState(false); const [installPath, setInstallPath] = useState(""); const [installing, setInstalling] = useState(false); const [reloadingPluginId, setReloadingPluginId] = useState(null); const [selectedPlugin, setSelectedPlugin] = useState(null); const [pluginSettings, setPluginSettings] = useState>({}); const [settingsLoading, setSettingsLoading] = useState(false); const loadPlugins = useCallback(async () => { try { setLoading(true); const data = await fetchPlugins(projectId); setPlugins(data); } catch (err) { addToast(`Failed to load plugins: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setLoading(false); } }, [projectId, addToast]); useEffect(() => { loadPlugins(); }, [loadPlugins]); // SSE live updates for plugin lifecycle events const pluginsRef = useRef([]); pluginsRef.current = plugins; useEffect(() => { let closedByCleanup = false; let heartbeatTimer: ReturnType | null = null; let reconnectTimer: ReturnType | null = null; const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const es = new EventSource(`/api/events${query}`); /** Reset the SSE heartbeat watchdog */ const resetHeartbeat = () => { if (heartbeatTimer) clearTimeout(heartbeatTimer); heartbeatTimer = setTimeout(() => { if (!closedByCleanup) { // Connection appears dead — force reconnect es.close(); if (!closedByCleanup) { reconnectTimer = setTimeout(() => { if (!closedByCleanup) { void loadPlugins(); // Fallback: refetch all plugins } }, 3000); } } }, SSE_HEARTBEAT_TIMEOUT_MS); }; // Start the heartbeat watchdog immediately resetHeartbeat(); const handlePluginLifecycle = (e: MessageEvent) => { resetHeartbeat(); try { const payload: PluginLifecyclePayload = JSON.parse(e.data); // Filter by projectId if in project-scoped mode if (projectId && payload.projectId && payload.projectId !== projectId) { return; } const currentPlugins = pluginsRef.current; switch (payload.transition) { case "installing": case "enabled": case "disabled": case "settings-updated": // Update existing plugin or add if new setPlugins((prev) => { const existingIndex = prev.findIndex((p) => p.id === payload.pluginId); if (existingIndex >= 0) { // Update existing plugin const updated = [...prev]; updated[existingIndex] = { ...updated[existingIndex], enabled: payload.enabled, state: payload.state, settings: payload.settings, error: payload.error, }; return updated; } else { // New plugin added via another session — refetch to get full data void loadPlugins(); return prev; } }); break; case "uninstalled": // Remove plugin from list setPlugins((prev) => prev.filter((p) => p.id !== payload.pluginId)); break; case "error": // Update plugin state to error setPlugins((prev) => { const existingIndex = prev.findIndex((p) => p.id === payload.pluginId); if (existingIndex >= 0) { const updated = [...prev]; updated[existingIndex] = { ...updated[existingIndex], state: payload.state, error: payload.error, }; return updated; } return prev; }); break; } } catch { // Ignore parse errors } }; es.addEventListener("plugin:lifecycle", handlePluginLifecycle); // Also listen for the heartbeat to keep the connection alive es.addEventListener("heartbeat", () => { resetHeartbeat(); }); es.onerror = () => { if (closedByCleanup) return; // EventSource will automatically attempt reconnection // We just need to clear our heartbeat watchdog if (heartbeatTimer) clearTimeout(heartbeatTimer); }; return () => { closedByCleanup = true; if (heartbeatTimer) clearTimeout(heartbeatTimer); if (reconnectTimer) clearTimeout(reconnectTimer); es.removeEventListener("plugin:lifecycle", handlePluginLifecycle); es.close(); }; }, [projectId, loadPlugins]); const handleInstall = async () => { if (!installPath.trim()) { addToast("Please enter a plugin path", "error"); return; } try { setInstalling(true); await installPlugin({ path: installPath }, projectId); addToast("Plugin installed successfully", "success"); setShowInstall(false); setInstallPath(""); await loadPlugins(); } catch (err) { addToast(`Failed to install plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setInstalling(false); } }; const handleEnable = async (plugin: PluginInstallation) => { try { await enablePlugin(plugin.id, projectId); addToast(`${plugin.name} enabled`, "success"); await loadPlugins(); } catch (err) { addToast(`Failed to enable plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; const handleDisable = async (plugin: PluginInstallation) => { try { await disablePlugin(plugin.id, projectId); addToast(`${plugin.name} disabled`, "success"); await loadPlugins(); } catch (err) { addToast(`Failed to disable plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; const handleReload = async (plugin: PluginInstallation) => { try { setReloadingPluginId(plugin.id); await reloadPlugin(plugin.id, projectId); addToast(`${plugin.name} reloaded`, "success"); await loadPlugins(); } catch (err) { addToast(`Failed to reload plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setReloadingPluginId(null); } }; const handleUninstall = async (plugin: PluginInstallation) => { if (!confirm(`Are you sure you want to uninstall "${plugin.name}"?`)) { return; } try { await uninstallPlugin(plugin.id, projectId); addToast(`${plugin.name} uninstalled`, "success"); await loadPlugins(); setSelectedPlugin(null); } catch (err) { addToast(`Failed to uninstall plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; const handleSelectPlugin = async (plugin: PluginInstallation) => { setSelectedPlugin(plugin); try { setSettingsLoading(true); const settings = await fetchPluginSettings(plugin.id, projectId); setPluginSettings(settings); } catch { setPluginSettings({}); } finally { setSettingsLoading(false); } }; const handleSaveSettings = async () => { if (!selectedPlugin) return; try { await updatePluginSettings(selectedPlugin.id, pluginSettings, projectId); addToast("Settings saved", "success"); } catch (err) { addToast(`Failed to save settings: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; // Plugin detail view if (selectedPlugin) { return (

{selectedPlugin.name}

{selectedPlugin.state}
{selectedPlugin.description && (

{selectedPlugin.description}

)} {selectedPlugin.author && (

Author: {selectedPlugin.author}

)} {selectedPlugin.homepage && (

Homepage: {selectedPlugin.homepage}

)}

Version: {selectedPlugin.version}

Settings

{settingsLoading ? (

Loading...

) : selectedPlugin.settingsSchema && Object.keys(selectedPlugin.settingsSchema).length > 0 ? (
{Object.entries(selectedPlugin.settingsSchema).map(([key, schema]) => { const helpId = `setting-${key}-help`; return (
{schema.type === "string" && !schema.multiline && ( setPluginSettings({ ...pluginSettings, [key]: e.target.value })} placeholder={schema.description} aria-describedby={schema.description && !schema.required ? helpId : undefined} /> )} {schema.type === "string" && schema.multiline && (