feat(frontend): système de plugins — runtime, menus, layouts custom (#43)
Lots F1-F4 : runtime de chargement/registre plugin, extension des menus existants, panneau de gestion des plugins, types de layout custom (sélecteur, fallback, cellule dédiée) branchés sur le port plugin. Suite npm typecheck/test verte (947/947). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
241
frontend/src/features/plugins/PluginsPanel.tsx
Normal file
241
frontend/src/features/plugins/PluginsPanel.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
/**
|
||||
* `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 { 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 [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>
|
||||
)}
|
||||
|
||||
{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.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");
|
||||
}
|
||||
Reference in New Issue
Block a user