From 44dc67722cb7a4bf0b36abf059e33e5bdded2174 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 8 Sep 2026 13:08:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20soft=20refresh=20UI=20des=20co?= =?UTF-8?q?ntributions=20plugins=20apr=C3=A8s=20install/reload=20=E2=80=94?= =?UTF-8?q?=20frontend=20#279=20(PluginRuntimeProvider=20refreshRuntime=20?= =?UTF-8?q?sur=20=C3=A9v=C3=A9nements=20pluginInstalled/pluginReloaded,=20?= =?UTF-8?q?dispose=20de=20l'ancien=20registry,=20catalogue=20listRuntimeCo?= =?UTF-8?q?ntributions,=20gateway=20mock;=20QA=20verte=20r=C3=A9-ex=C3=A9c?= =?UTF-8?q?ut=C3=A9e:=2035/35=20tests=20features/plugins=20dont=20nouveau?= =?UTF-8?q?=20PluginRuntimeProvider.test,=20typecheck=20OK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/adapters/mock/index.ts | 4 +- frontend/src/domain/index.ts | 8 + .../plugins/PluginRuntimeProvider.test.tsx | 182 ++++++++++++++++++ .../plugins/PluginRuntimeProvider.tsx | 131 ++++++------- .../src/features/plugins/plugins.test.tsx | 6 +- frontend/src/plugins/runtime/loader.ts | 8 +- 6 files changed, 259 insertions(+), 80 deletions(-) create mode 100644 frontend/src/features/plugins/PluginRuntimeProvider.test.tsx diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index e9fbb5c9..487627ae 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -4051,12 +4051,12 @@ export class MockPluginGateway implements PluginGateway { lifecycleState: "enabled", enabled: true, pendingUninstall: false, - restartRequired: true, + restartRequired: false, trustLevel: "full", contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`), }; this.plugins.push(plugin); - return { plugin: structuredClone(plugin), restartRequired: true }; + return { plugin: structuredClone(plugin), restartRequired: false }; } installFromArchive(path: string): Promise { diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 2ce38738..0f702d3d 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -282,6 +282,14 @@ export interface EmbeddedServerStatus { /** A domain event relayed from the backend (tagged union on `type`). */ export type DomainEvent = | { type: "projectCreated"; projectId: string } + | { type: "pluginInstalled"; pluginId: string; version: string } + | { + type: "pluginReloaded"; + pluginId: string; + oldContentHash: string; + newContentHash: string; + changed: boolean; + } | { type: "agentLaunched"; agentId: string; sessionId: string } | { type: "agentExited"; agentId: string; code: number } | { diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx new file mode 100644 index 00000000..0a6b7149 --- /dev/null +++ b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { createMockGateways, MockPluginGateway, MockSystemGateway } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import type { + CustomPluginLayoutCell, + PluginRuntimeContributionCatalog, + PluginRuntimePlugin, +} from "@/domain"; +import { usePluginRuntime } from "./PluginRuntimeProvider"; +import { PluginLayoutCellView } from "./PluginLayoutCellView"; +import { PluginRuntimeProvider } from "./PluginRuntimeProvider"; + +function dataUrl(source: string): string { + return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; +} + +function runtimePlugin(overrides: Partial = {}): PluginRuntimePlugin { + return { + id: "dev.acme.refreshable", + displayName: "Refreshable v1", + version: "1.0.0", + bundleUrl: dataUrl("export function activate() {}"), + contentHash: "v1", + contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] }, + ...overrides, + }; +} + +class MutableRuntimeCatalogGateway extends MockPluginGateway { + calls = 0; + catalog: PluginRuntimeContributionCatalog = { plugins: [] }; + + async listRuntimeContributions(): Promise { + this.calls += 1; + return this.catalog; + } +} + +function RuntimeProbe({ onMount }: { onMount: () => void }) { + const { registry, loading } = usePluginRuntime(); + const [workspaceState, setWorkspaceState] = useState("initial"); + + useEffect(onMount, [onMount]); + + return ( +
+ + {loading ? "loading" : (registry.list()[0]?.displayName ?? "none")} + + {workspaceState} + +
+ ); +} + +describe("PluginRuntimeProvider soft refresh (#279)", () => { + it("reloads contributions after install and reload events without remounting its workspace", async () => { + const plugin = new MutableRuntimeCatalogGateway(); + const system = new MockSystemGateway(); + const gateways = { ...createMockGateways(), plugin, system }; + let mounts = 0; + const onMount = () => { + mounts += 1; + }; + + render( + + + + + , + ); + + expect(await screen.findByText("none")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "change workspace" })); + + plugin.catalog = { plugins: [runtimePlugin()] }; + system.emit({ + type: "pluginInstalled", + pluginId: "dev.acme.refreshable", + version: "1.0.0", + }); + expect(await screen.findByText("Refreshable v1")).toBeTruthy(); + + plugin.catalog = { + plugins: [ + runtimePlugin({ + displayName: "Refreshable v2", + version: "2.0.0", + contentHash: "v2", + bundleUrl: dataUrl("export function activate() { return { dispose() {} }; }"), + }), + ], + }; + system.emit({ + type: "pluginReloaded", + pluginId: "dev.acme.refreshable", + oldContentHash: "v1", + newContentHash: "v2", + changed: true, + }); + + expect(await screen.findByText("Refreshable v2")).toBeTruthy(); + expect(screen.getByTestId("workspace-state").textContent).toBe("preserved"); + expect(mounts).toBe(1); + expect(plugin.calls).toBe(3); + }); + + it("keeps a custom plugin layout cell mounted as a fallback when its contribution disappears", async () => { + const plugin = new MutableRuntimeCatalogGateway(); + const system = new MockSystemGateway(); + const gateways = { ...createMockGateways(), plugin, system }; + const testCell: CustomPluginLayoutCell = { + id: "plugin-cell-1", + pluginId: "dev.acme.refreshable", + layoutType: "dev.acme.refreshable.layout", + state: { tab: "history" }, + }; + plugin.catalog = { + plugins: [ + runtimePlugin({ + bundleUrl: dataUrl(` + export function activate(ctx) { + ctx.layouts.register({ + type: "dev.acme.refreshable.layout", + component: () => "Plugin layout" + }); + } + `), + contributes: { + menus: [], + menuItems: [], + layouts: [ + { + type: "dev.acme.refreshable.layout", + label: "Refreshable layout", + component: "RefreshableLayout" + } + ], + mcpServers: [], + }, + }), + ], + }; + + render( + + + {}} + onOpenPlugins={() => {}} + onChooseAnotherLayout={() => {}} + /> + + , + ); + + expect(await screen.findByText("Plugin layout")).toBeTruthy(); + plugin.catalog = { plugins: [] }; + system.emit({ + type: "pluginReloaded", + pluginId: testCell.pluginId, + oldContentHash: "v1", + newContentHash: "v2", + changed: true, + }); + + expect(await screen.findByText("Layout indisponible")).toBeTruthy(); + expect(testCell).toEqual({ + id: "plugin-cell-1", + pluginId: "dev.acme.refreshable", + layoutType: "dev.acme.refreshable.layout", + state: { tab: "history" }, + }); + await waitFor(() => expect(plugin.calls).toBe(2)); + }); +}); diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.tsx index be35fc96..4ef66fbc 100644 --- a/frontend/src/features/plugins/PluginRuntimeProvider.tsx +++ b/frontend/src/features/plugins/PluginRuntimeProvider.tsx @@ -1,14 +1,13 @@ /** - * `PluginRuntimeProvider` — bootstraps the plugin runtime once per app session - * (ticket #43, F1, carnet §1.3): fetches the runtime catalog (already filtered + * `PluginRuntimeProvider` — owns the refreshable plugin runtime for one window. + * It fetches the runtime catalog (already filtered * to `enabled && !pendingUninstall` by the backend) and loads every bundle via * {@link loadPlugins}, then exposes the resulting {@link PluginRuntimeRegistry} * to the rest of the tree (menus in F3, layouts in F4). * - * Mounted once near the app root, inside `` (it reads gateways via - * `useGateways()`). Never re-runs within a session — carnet §1.3: disable/ - * uninstall only mask contributions in-session; the actual reload happens on - * the next app start (a fresh mount of this provider). + * Mounted near the window root, inside `` (it reads gateways via + * `useGateways()`). Install/reload domain events rebuild only this context value: + * the workspace below stays mounted and retains all non-plugin UI/runtime state. */ import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; @@ -73,6 +72,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti useEffect(() => { if (injected) return; let cancelled = false; + let refreshRevision = 0; const pluginGateways = { project: gateways.project, git: gateways.git, @@ -89,89 +89,78 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti pluginConfig: gateways.pluginConfig, pluginStorage: gateways.pluginStorage, }; - let activatedProjectScoped = false; let pendingProjectPlugins: PluginRuntimePlugin[] = []; let unsubscribeFocus: Unsubscribe | undefined; + let unsubscribeDomainEvents: Unsubscribe | undefined; + let activeRegistry = value.registry; - void gateways.focusedProject - .onFocusedProjectChanged(async (project) => { - if (!project || activatedProjectScoped || pendingProjectPlugins.length === 0) return; - activatedProjectScoped = true; - const projectPlugins = pendingProjectPlugins; - pendingProjectPlugins = []; - try { - const result = await loadPlugins(projectPlugins, pluginGateways); - if (cancelled) return; - setValue((prev) => { - for (const plugin of result.registry.list()) prev.registry.add(plugin); - return { - registry: prev.registry, - failures: [...prev.failures, ...result.failures], - pending: prev.pending.filter( - (p) => !projectPlugins.some((entry) => entry.id === p.pluginId), - ), - loading: false, - }; - }); - } catch (e: unknown) { - if (cancelled) return; - setValue((prev) => ({ - ...prev, - failures: [ - ...prev.failures, - ...projectPlugins.map((plugin) => ({ - pluginId: plugin.id, - reason: describeError(e), - })), - ], - pending: prev.pending.filter( - (p) => !projectPlugins.some((entry) => entry.id === p.pluginId), - ), - loading: false, - })); - } - }) - .then((unsubscribe) => { - if (cancelled) unsubscribe(); - else unsubscribeFocus = unsubscribe; - }); + async function disposeRegistry(registry: PluginRuntimeRegistry): Promise { + await Promise.allSettled( + registry.list().map((plugin) => registry.remove(plugin.pluginId)), + ); + } - gateways.plugin - .listRuntimeContributions() - .then(async (catalog) => { + async function refreshRuntime(initial: boolean): Promise { + const revision = ++refreshRevision; + try { + const catalog = await gateways.plugin.listRuntimeContributions(); const result = await loadPlugins(catalog.plugins, pluginGateways); + if (cancelled || revision !== refreshRevision) { + await disposeRegistry(result.registry); + return; + } pendingProjectPlugins = catalog.plugins.filter((entry) => - result.pending.some((p) => p.pluginId === entry.id), + result.pending.some((pending) => pending.pluginId === entry.id), ); - return result; - }) - .then((result) => { - if (cancelled) return; + const previousRegistry = activeRegistry; + activeRegistry = result.registry; setValue({ registry: result.registry, failures: result.failures, pending: result.pending, loading: false, }); + if (previousRegistry !== result.registry) void disposeRegistry(previousRegistry); + } catch (e: unknown) { + if (cancelled || revision !== refreshRevision) return; + const failure = { pluginId: "", reason: describeError(e) }; + setValue((prev) => ({ + ...prev, + failures: initial ? [failure] : [...prev.failures, failure], + pending: initial ? [] : prev.pending, + loading: false, + })); + } + } + + void gateways.focusedProject + .onFocusedProjectChanged((project) => { + if (!project || pendingProjectPlugins.length === 0) return; + void refreshRuntime(false); }) - .catch((e: unknown) => { - // No plugin gateway / catalog fetch failed: run with zero plugins - // rather than blocking the app (full-trust plugins are additive). - if (!cancelled) { - setValue((prev) => ({ - ...prev, - failures: [ - ...prev.failures, - { pluginId: "", reason: describeError(e) }, - ], - pending: [], - loading: false, - })); - } + .then((unsubscribe) => { + if (cancelled) unsubscribe(); + else unsubscribeFocus = unsubscribe; }); + + void gateways.system + .onDomainEvent((event) => { + if (event.type === "pluginInstalled" || event.type === "pluginReloaded") { + void refreshRuntime(false); + } + }) + .then((unsubscribe) => { + if (cancelled) unsubscribe(); + else unsubscribeDomainEvents = unsubscribe; + }); + + void refreshRuntime(true); return () => { cancelled = true; + refreshRevision += 1; unsubscribeFocus?.(); + unsubscribeDomainEvents?.(); + void disposeRegistry(activeRegistry); }; // Gateways are a stable singleton for the app session (from `useGateways`); // re-running on every render would reload every plugin bundle. diff --git a/frontend/src/features/plugins/plugins.test.tsx b/frontend/src/features/plugins/plugins.test.tsx index 6b68c804..782f037b 100644 --- a/frontend/src/features/plugins/plugins.test.tsx +++ b/frontend/src/features/plugins/plugins.test.tsx @@ -1,7 +1,7 @@ /** * F2 — `PluginsPanel` (ticket #43, carnet §10 F2 acceptance criteria): install * from archive/directory with pre-install review, full-trust mention visible, - * enable/disable/uninstall flows, and `restartRequired` rendered. + * enable/disable/uninstall flows, and lifecycle feedback rendered. */ import { describe, it, expect } from "vitest"; import { render, screen, waitFor, fireEvent, within } from "@testing-library/react"; @@ -327,9 +327,9 @@ describe("PluginsPanel", () => { fireEvent.click(within(dialog).getByRole("button", { name: "Installer" })); - // The installed plugin now shows in the list, restart required (carnet §1.4). + // Install is live: the plugin is immediately active without a restart notice. expect(await screen.findByText("mock-plugin")).toBeTruthy(); - expect(screen.getByText("Redémarrage requis")).toBeTruthy(); + expect(screen.queryByText("Redémarrage requis")).toBeNull(); expect(screen.getByText("Activé")).toBeTruthy(); }); diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 50ec18f5..2914071f 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -1,7 +1,8 @@ /** * Plugin bootstrap loader (ticket #43, F1, carnet §1.3/§6). * - * At UI bootstrap, the app calls {@link loadPlugins} once with the catalog + * At UI bootstrap and after an install/reload event, the app calls + * {@link loadPlugins} with the catalog * from `PluginGateway.listRuntimeContributions()` (already filtered by the * backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set * used to build the public service facade. For each entry it dynamically imports @@ -370,9 +371,8 @@ function createPluginStorage(gateways: PluginGatewaySet, pluginId: string): Plug /** * Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}. - * Called once at app bootstrap (and never again in the same session — carnet - * §1.3: disable/uninstall only masks contributions in-session, the actual - * unload happens on next restart via a fresh `loadPlugins` call). + * The provider swaps this fresh snapshot atomically at bootstrap and after a + * supported plugin lifecycle event. */ export async function loadPlugins( catalogPlugins: PluginRuntimePlugin[],