- usePlugins.ts: remplace les mises à jour optimistes du state par des appels listPlugins() après chaque opération backend - PluginsPanel.tsx: ajoute la normalisation isInstallable() pour éviter les crashes si installable est undefined - Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
274 lines
9.9 KiB
TypeScript
274 lines
9.9 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={
|
|
isInstallable(installFlow.review) ? "Installer" : "Corriger avant d'installer"
|
|
}
|
|
busy={vm.busy}
|
|
danger={!isInstallable(installFlow.review)}
|
|
onConfirm={() => {
|
|
if (isInstallable(installFlow.review)) 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");
|
|
}
|
|
|
|
function isInstallable(review: PluginReview): boolean {
|
|
return review.installable ?? true;
|
|
}
|