Files
IdeA/frontend/src/features/plugins/PluginsPanel.tsx
Blomios 46086af026 fix(plugins): support export default { activate }, cleanup partiel et erreurs visibles
Le loader accepte désormais aussi la forme export default { activate }
en plus de l'export nommé, les subscriptions partiellement établies
sont nettoyées en cas d'échec d'activation, et une erreur de
runtime-catalog est remontée visuellement dans PluginsPanel avec un
badge Invalide/Isolé sur les plugins concernés (#116/#120).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 00:05:57 +02:00

266 lines
9.7 KiB
TypeScript

/**
* `PluginsPanel` — the `Paramètres > Plugins` admin surface (ticket #43, F2).
*
* Pure presentation; all behaviour comes from {@link usePlugins}. List of
* installed plugins (name/publisher/version/source/state/restartRequired/
* trust), install from archive/directory with a pre-install review step
* (manifest summary + explicit full-trust mention), enable/disable/uninstall
* with confirmation, and readable `pending`/`invalid`/error states.
*/
import { useState } from "react";
import type { PluginAdmin, PluginLifecycleState, PluginReview } from "@/domain";
import { Button, Panel } from "@/shared";
import { PluginConfirmDialog as ConfirmDialog } from "./PluginConfirmDialog";
import { usePluginRuntime } from "./PluginRuntimeProvider";
import { usePlugins } from "./usePlugins";
const STATE_LABEL: Record<PluginLifecycleState, string> = {
enabled: "Activé",
disabled: "Désactivé",
"pending-enable": "Activation en attente",
"pending-disable": "Désactivation en attente",
"pending-uninstall": "Désinstallation en attente",
invalid: "Invalide",
};
type PendingAction =
| { kind: "disable"; plugin: PluginAdmin }
| { kind: "uninstall"; plugin: PluginAdmin };
interface InstallFlow {
sourceKind: "archive" | "directory";
path: string;
review: PluginReview;
}
export function PluginsPanel() {
const vm = usePlugins();
const pluginRuntime = usePluginRuntime();
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [installFlow, setInstallFlow] = useState<InstallFlow | null>(null);
const [installFlowError, setInstallFlowError] = useState<string | null>(null);
async function startInstallFromArchive() {
setInstallFlowError(null);
const path = await vm.pickArchiveFile();
if (!path) return;
const review = await vm.reviewArchive(path);
if (!review) {
setInstallFlowError("La revue du paquet a échoué.");
return;
}
setInstallFlow({ sourceKind: "archive", path, review });
}
async function startInstallFromDirectory() {
setInstallFlowError(null);
const path = await vm.pickDirectory();
if (!path) return;
const review = await vm.reviewDirectory(path);
if (!review) {
setInstallFlowError("La revue du paquet a échoué.");
return;
}
setInstallFlow({ sourceKind: "directory", path, review });
}
async function confirmInstall() {
if (!installFlow) return;
const ok =
installFlow.sourceKind === "archive"
? await vm.installFromArchive(installFlow.path)
: await vm.installFromDirectory(installFlow.path);
if (ok) setInstallFlow(null);
}
async function confirmPendingAction() {
if (!pendingAction) return;
if (pendingAction.kind === "disable") {
await vm.setEnabled(pendingAction.plugin.id, false);
} else {
await vm.uninstall(pendingAction.plugin.id);
}
setPendingAction(null);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-content">Plugins</h2>
<div className="flex gap-2">
<Button size="sm" onClick={() => void startInstallFromArchive()} disabled={vm.busy}>
Installer depuis une archive
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => void startInstallFromDirectory()}
disabled={vm.busy}
>
Installer depuis un dossier
</Button>
</div>
</div>
{(vm.error || installFlowError) && (
<p role="alert" className="text-sm text-danger">
{vm.error ?? installFlowError}
</p>
)}
{pluginRuntime.failures.length > 0 && (
<Panel className="border-warning/40">
<div className="flex flex-col gap-1">
<p role="alert" className="text-sm font-medium text-warning">
Certains plugins installés n'ont pas pu être chargés.
</p>
<ul className="flex flex-col gap-0.5">
{pluginRuntime.failures.map((failure) => (
<li key={`${failure.pluginId}:${failure.reason}`} className="text-xs text-muted">
<span className="font-medium text-content">{failure.pluginId}</span> :{" "}
{failure.reason}
</li>
))}
</ul>
</div>
</Panel>
)}
{vm.plugins.length === 0 ? (
<Panel>
<p className="text-sm text-muted">Aucun plugin installé.</p>
</Panel>
) : (
<ul className="flex flex-col gap-2">
{vm.plugins.map((p) => (
<li key={p.id}>
<Panel className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-content">{p.displayName}</span>
<span className="text-xs text-faint">v{p.version}</span>
{p.publisher && <span className="text-xs text-faint">· {p.publisher}</span>}
<span className="rounded bg-raised px-1.5 py-0.5 text-[0.65rem] uppercase text-muted">
full-trust
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted">
<span
aria-label="plugin state"
className={
p.lifecycleState === "invalid" ? "text-danger" : undefined
}
>
{STATE_LABEL[p.lifecycleState]}
</span>
{p.restartRequired && (
<span className="text-warning">Redémarrage requis</span>
)}
{p.sourceLabel && <span>· {p.sourceLabel}</span>}
</div>
{p.error && (
<p className="text-xs text-danger" role="alert">
{p.error}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{p.lifecycleState === "invalid" ? (
<Button size="sm" variant="ghost" disabled>
Isolé
</Button>
) : p.enabled ? (
<Button
size="sm"
variant="ghost"
onClick={() => setPendingAction({ kind: "disable", plugin: p })}
disabled={vm.busy}
>
Désactiver
</Button>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => void vm.setEnabled(p.id, true)}
disabled={vm.busy}
>
Activer
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => void vm.openPluginsFolder(p.id)}
disabled={vm.busy}
>
Ouvrir le dossier
</Button>
<Button
size="sm"
variant="danger"
onClick={() => setPendingAction({ kind: "uninstall", plugin: p })}
disabled={vm.busy}
>
Désinstaller
</Button>
</div>
</Panel>
</li>
))}
</ul>
)}
{installFlow && (
<ConfirmDialog
title={`Installer "${installFlow.review.displayName}" ?`}
body={reviewSummary(installFlow.review)}
confirmLabel={installFlow.review.installable ? "Installer" : "Corriger avant d'installer"}
busy={vm.busy}
danger={!installFlow.review.installable}
onConfirm={() => {
if (installFlow.review.installable) void confirmInstall();
else setInstallFlow(null);
}}
onCancel={() => setInstallFlow(null)}
/>
)}
{pendingAction && (
<ConfirmDialog
title={
pendingAction.kind === "disable"
? `Désactiver "${pendingAction.plugin.displayName}" ?`
: `Désinstaller "${pendingAction.plugin.displayName}" ?`
}
body={
pendingAction.kind === "disable"
? "Ses contributions seront masquées immédiatement ; un redémarrage sera peut-être nécessaire pour purger complètement le plugin."
: "Le plugin, ses fichiers et ses contributions seront supprimés après redémarrage."
}
confirmLabel={pendingAction.kind === "disable" ? "Désactiver" : "Désinstaller"}
danger={pendingAction.kind === "uninstall"}
busy={vm.busy}
onConfirm={() => void confirmPendingAction()}
onCancel={() => setPendingAction(null)}
/>
)}
</div>
);
}
function reviewSummary(review: PluginReview): string {
const { contributionSummary: c } = review;
const parts = [
`Éditeur : ${review.publisher ?? "inconnu"}`,
`Version : ${review.version}`,
review.description ?? null,
`Confiance : full-trust — ce plugin peut exécuter du code arbitraire dans IdeA.`,
`Contributions : ${c.topLevelMenus} menu(s), ${c.menuItems} item(s), ${c.layouts} layout(s), ${c.mcpServers} serveur(s) MCP.`,
...review.issues.map((i) => `${i.severity === "error" ? "Erreur" : "Avertissement"} : ${i.message}`),
].filter((p): p is string => Boolean(p));
return parts.join("\n");
}