feat(application,backend,infrastructure,app-tauri,frontend): contributions UI/runtime des plugins limitées aux projets liés — #321 (QA verte)

Les contributions runtime des plugins (menus, slash commands, layouts,
surfaces) ne sont exposées que dans les projets liés au plugin.

- Définition du lien: ProjectPluginSettingsStore par racine projet
  (domaine) — une contribution runtime-active n'entre dans le catalogue
  que si elle est explicitement activée pour le projet courant; hors
  projet focalisé, catalogue vide
- Application: ListPluginRuntimeContributions.execute prend un
  project_id optionnel, charge projet + settings et filtre le registre
  par activation projet; tests fake store/settings (catalogue vide sans
  projet, activation, layout, slash commands)
- Backend/app-tauri: plugin_list_runtime_contributions et slash
  commands list/execute acceptent projectId optionnel (DTO camelCase,
  défaut serde), BackendCore recâblé avec store_port et
  project_plugin_settings_store; test DTO de scope optionnel
- Frontend: PluginGateway.listRuntimeContributions(projectId) (ports,
  tauri, http/unsupported, mock alignés); PluginRuntimeProvider
  re-scope le catalogue au changement de focus projet (vidage du
  registre précédent, disposition des bundles, requêtes obsolètes
  ignorées par projectId/révision); PluginLayoutCellView ignore les
  références de layout devenues hors périmètre via onChooseAnotherLayout
  et ne rend rien pendant le rechargement (aucune fuite inter-projets)
- Tests: plugin_install_load (infrastructure), dto slash commands,
  plugin.test, PluginRuntimeProvider.test, PluginLayoutCellView.test

QA verte sur feature/321-plugin-contributions-linked-projects. Aucun push.
This commit is contained in:
2026-09-11 17:46:03 +02:00
parent 6bdbe755fa
commit bf33e58508
16 changed files with 523 additions and 110 deletions

View File

@ -191,7 +191,7 @@ export class WebPluginGateway implements PluginGateway {
async uninstall(_pluginId: string): Promise<PluginUninstallResult> {
return unsupportedOnWeb("Plugin management");
}
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
async listRuntimeContributions(_projectId?: string): Promise<PluginRuntimeContributionCatalog> {
// No plugin bundles are ever loaded on the web client — an empty catalog
// lets the bootstrap loader run unconditionally without a transport check.
return { plugins: [] };

View File

@ -4424,7 +4424,7 @@ export class MockPluginGateway implements PluginGateway {
return { pluginId, removalOutcome: "removed", restartRequired: true };
}
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
async listRuntimeContributions(_projectId?: string): Promise<PluginRuntimeContributionCatalog> {
// Only enabled, non-pending-uninstall plugins are loadable at bootstrap
// (carnet §1.3) — the mock has no bundle to actually import, so this
// starts empty; tests seed `plugins` on `PluginRuntimeContributionCatalog`

View File

@ -82,4 +82,14 @@ describe("TauriPluginGateway invoke payloads", () => {
await gateway.unassignAgentTool(toolInput);
expect(invoke).toHaveBeenLastCalledWith("plugin_unassign_agent_tool", { input: toolInput });
});
it("scopes runtime contributions to the focused project", async () => {
const gateway = new TauriPluginGateway();
await gateway.listRuntimeContributions("project-1");
expect(invoke).toHaveBeenCalledWith("plugin_list_runtime_contributions", {
projectId: "project-1",
});
});
});

View File

@ -70,8 +70,10 @@ export class TauriPluginGateway implements PluginGateway {
return invoke<PluginUninstallResult>("plugin_uninstall", { pluginId });
}
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
return invoke<PluginRuntimeContributionCatalog>("plugin_list_runtime_contributions");
listRuntimeContributions(projectId?: string): Promise<PluginRuntimeContributionCatalog> {
return invoke<PluginRuntimeContributionCatalog>("plugin_list_runtime_contributions", {
projectId: projectId ?? null,
});
}
listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView> {

View File

@ -1,9 +1,9 @@
/**
* F4 — `PluginLayoutCellView` (ticket #43, carnet §10 F4 acceptance criteria:
* "rendu composant mock, state roundtrip, fallback sans mutation du layout").
* "rendu composant mock, state roundtrip, isolation des surfaces indisponibles").
*/
import { describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { CustomPluginLayoutCell } from "@/domain";
import type { Gateways } from "@/ports";
@ -68,7 +68,11 @@ function stubPlugin(component = MockLayoutComponent): LoadedPlugin {
function renderCell(
registry: PluginRuntimeRegistry,
props: Partial<{ cell: CustomPluginLayoutCell; onStateChange: (s: unknown) => void }> = {},
props: Partial<{
cell: CustomPluginLayoutCell;
onStateChange: (s: unknown) => void;
onChooseAnotherLayout: () => void;
}> = {},
) {
const gateways: Gateways = createMockGateways();
return render(
@ -79,7 +83,7 @@ function renderCell(
cell={props.cell ?? cell()}
onStateChange={props.onStateChange ?? (() => {})}
onOpenPlugins={() => {}}
onChooseAnotherLayout={() => {}}
onChooseAnotherLayout={props.onChooseAnotherLayout ?? (() => {})}
/>
</PluginRuntimeProvider>
</DIProvider>,
@ -111,13 +115,14 @@ describe("PluginLayoutCellView", () => {
expect(lastState).toEqual({ commits: 4 });
});
it("renders the non-destructive fallback when the provider is not loaded, without mutating the cell", () => {
it("ignores an unavailable layout reference without rendering a disabled surface", async () => {
const registry = new PluginRuntimeRegistry(); // empty — provider not loaded
const testCell = cell();
renderCell(registry, { cell: testCell });
const onChooseAnotherLayout = vi.fn();
renderCell(registry, { cell: testCell, onChooseAnotherLayout });
expect(screen.getByText("Layout indisponible")).toBeTruthy();
// The cell object itself is untouched — the domain identity/state survive.
expect(screen.queryByText("Layout indisponible")).toBeNull();
await waitFor(() => expect(onChooseAnotherLayout).toHaveBeenCalledOnce());
expect(testCell).toEqual(cell());
});

View File

@ -10,7 +10,7 @@
* calls from inside a plugin component).
*/
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Component, useEffect, type ErrorInfo, type ReactNode } from "react";
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
import { usePluginRuntime } from "./PluginRuntimeProvider";
@ -66,23 +66,21 @@ export function PluginLayoutCellView({
onOpenPlugins,
onChooseAnotherLayout,
}: PluginLayoutCellViewProps) {
const { registry } = usePluginRuntime();
const { registry, loading } = usePluginRuntime();
const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins);
const providerDisplayName =
registry.get(cell.pluginId)?.displayName ??
installedPlugins?.find((p) => p.id === cell.pluginId)?.displayName;
if (availability !== "available") {
return (
<PluginLayoutFallback
cell={cell}
availability={availability}
providerDisplayName={providerDisplayName}
onOpenPlugins={onOpenPlugins}
onChooseAnotherLayout={onChooseAnotherLayout}
/>
);
}
useEffect(() => {
if (!loading && availability !== "available") onChooseAnotherLayout();
}, [availability, loading, onChooseAnotherLayout]);
// A layout contribution absent from the current project's runtime catalog
// is not a disabled surface: its stale reference is ignored/closed by the
// owner callback. While the project-scoped catalog is changing, render
// nothing so contributions from the previous project cannot leak through.
if (loading || availability !== "available") return null;
const Component = registry.layoutComponent(cell.pluginId, cell.layoutType)!;
const fallback = (

View File

@ -1,8 +1,13 @@
import { useEffect, useState } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { createMockGateways, MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
import {
createMockGateways,
MockFocusedProjectGateway,
MockPluginGateway,
MockSystemGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import type {
CustomPluginLayoutCell,
@ -33,10 +38,26 @@ function runtimePlugin(overrides: Partial<PluginRuntimePlugin> = {}): PluginRunt
class MutableRuntimeCatalogGateway extends MockPluginGateway {
calls = 0;
catalog: PluginRuntimeContributionCatalog = { plugins: [] };
catalogs = new Map<string, PluginRuntimeContributionCatalog>();
projectIds: Array<string | undefined> = [];
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
async listRuntimeContributions(projectId?: string): Promise<PluginRuntimeContributionCatalog> {
this.calls += 1;
return this.catalog;
this.projectIds.push(projectId);
return (projectId ? this.catalogs.get(projectId) : undefined) ?? this.catalog;
}
}
class DeferredRuntimeCatalogGateway extends MockPluginGateway {
requests: Array<{
projectId: string | undefined;
resolve: (catalog: PluginRuntimeContributionCatalog) => void;
}> = [];
async listRuntimeContributions(projectId?: string): Promise<PluginRuntimeContributionCatalog> {
return new Promise((resolve) => {
this.requests.push({ projectId, resolve });
});
}
}
@ -61,7 +82,9 @@ 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 };
const focusedProject = new MockFocusedProjectGateway();
await focusedProject.setFocusedProject({ id: "project-1", name: "Alpha", root: "/alpha" });
const gateways = { ...createMockGateways(), plugin, system, focusedProject };
let mounts = 0;
const onMount = () => {
mounts += 1;
@ -108,12 +131,16 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
expect(screen.getByTestId("workspace-state").textContent).toBe("preserved");
expect(mounts).toBe(1);
expect(plugin.calls).toBe(3);
expect(plugin.projectIds).toEqual(["project-1", "project-1", "project-1"]);
});
it("keeps a custom plugin layout cell mounted as a fallback when its contribution disappears", async () => {
it("ignores a custom plugin layout ref when its contribution disappears", async () => {
const plugin = new MutableRuntimeCatalogGateway();
const system = new MockSystemGateway();
const gateways = { ...createMockGateways(), plugin, system };
const focusedProject = new MockFocusedProjectGateway();
await focusedProject.setFocusedProject({ id: "project-1", name: "Alpha", root: "/alpha" });
const gateways = { ...createMockGateways(), plugin, system, focusedProject };
const onChooseAnotherLayout = vi.fn();
const testCell: CustomPluginLayoutCell = {
id: "plugin-cell-1",
pluginId: "dev.acme.refreshable",
@ -155,7 +182,7 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
cell={testCell}
onStateChange={() => {}}
onOpenPlugins={() => {}}
onChooseAnotherLayout={() => {}}
onChooseAnotherLayout={onChooseAnotherLayout}
/>
</PluginRuntimeProvider>
</DIProvider>,
@ -171,7 +198,8 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
changed: true,
});
expect(await screen.findByText("Layout indisponible")).toBeTruthy();
await waitFor(() => expect(onChooseAnotherLayout).toHaveBeenCalledOnce());
expect(screen.queryByText("Layout indisponible")).toBeNull();
expect(testCell).toEqual({
id: "plugin-cell-1",
pluginId: "dev.acme.refreshable",
@ -180,4 +208,71 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
});
await waitFor(() => expect(plugin.calls).toBe(2));
});
it("replaces the runtime registry when the focused project changes", async () => {
const plugin = new MutableRuntimeCatalogGateway();
const focusedProject = new MockFocusedProjectGateway();
await focusedProject.setFocusedProject({ id: "project-a", name: "Alpha", root: "/alpha" });
plugin.catalogs.set("project-a", {
plugins: [runtimePlugin({ displayName: "Plugin Alpha" })],
});
plugin.catalogs.set("project-b", { plugins: [] });
const gateways = { ...createMockGateways(), plugin, focusedProject };
render(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider>
<RuntimeProbe onMount={() => {}} />
</PluginRuntimeProvider>
</DIProvider>,
);
expect(await screen.findByText("Plugin Alpha")).toBeTruthy();
await focusedProject.setFocusedProject({ id: "project-b", name: "Beta", root: "/beta" });
expect(screen.queryByText("Plugin Alpha")).toBeNull();
expect(await screen.findByText("none")).toBeTruthy();
expect(plugin.projectIds).toEqual(["project-a", "project-b"]);
});
it("ignores a stale runtime catalog response after the focused project changes", async () => {
const plugin = new DeferredRuntimeCatalogGateway();
const focusedProject = new MockFocusedProjectGateway();
await focusedProject.setFocusedProject({ id: "project-a", name: "Alpha", root: "/alpha" });
const gateways = { ...createMockGateways(), plugin, focusedProject };
render(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider>
<RuntimeProbe onMount={() => {}} />
</PluginRuntimeProvider>
</DIProvider>,
);
await waitFor(() => expect(plugin.requests).toHaveLength(1));
expect(plugin.requests[0].projectId).toBe("project-a");
await focusedProject.setFocusedProject({ id: "project-b", name: "Beta", root: "/beta" });
await waitFor(() => expect(plugin.requests).toHaveLength(2));
expect(plugin.requests[1].projectId).toBe("project-b");
await act(async () => {
plugin.requests[1].resolve({
plugins: [runtimePlugin({ displayName: "Plugin Beta" })],
});
});
expect(await screen.findByText("Plugin Beta")).toBeTruthy();
await act(async () => {
plugin.requests[0].resolve({
plugins: [runtimePlugin({ displayName: "Plugin Alpha" })],
});
});
await waitFor(() =>
expect(screen.getByTestId("runtime-plugin").textContent).toBe("Plugin Beta"),
);
expect(screen.queryByText("Plugin Alpha")).toBeNull();
});
});

View File

@ -1,7 +1,7 @@
/**
* `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
* It fetches the runtime catalog (already filtered to plugins linked to the
* focused project and runtime-active 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).
*
@ -19,7 +19,7 @@ import {
type PluginLoadPending,
} from "@/plugins/runtime";
import { useGateways } from "@/app/di";
import type { PluginRuntimePlugin, Unsubscribe } from "@/domain";
import type { Unsubscribe } from "@/domain";
export interface PluginRuntimeContextValue {
registry: PluginRuntimeRegistry;
@ -89,10 +89,11 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
pluginConfig: gateways.pluginConfig,
pluginStorage: gateways.pluginStorage,
};
let pendingProjectPlugins: PluginRuntimePlugin[] = [];
let unsubscribeFocus: Unsubscribe | undefined;
let unsubscribeDomainEvents: Unsubscribe | undefined;
let activeRegistry = value.registry;
let currentProjectId: string | undefined;
let focusRevision = 0;
async function disposeRegistry(registry: PluginRuntimeRegistry): Promise<void> {
await Promise.allSettled(
@ -100,18 +101,32 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
);
}
function clearRuntimeForProjectChange(): void {
const previousRegistry = activeRegistry;
activeRegistry = new PluginRuntimeRegistry();
setValue({
registry: activeRegistry,
failures: [],
pending: [],
loading: true,
});
if (previousRegistry !== activeRegistry) void disposeRegistry(previousRegistry);
}
async function refreshRuntime(initial: boolean): Promise<void> {
const revision = ++refreshRevision;
const projectId = currentProjectId;
try {
const catalog = await gateways.plugin.listRuntimeContributions();
const catalog = await gateways.plugin.listRuntimeContributions(projectId);
const result = await loadPlugins(catalog.plugins, pluginGateways);
if (cancelled || revision !== refreshRevision) {
if (
cancelled ||
revision !== refreshRevision ||
projectId !== currentProjectId
) {
await disposeRegistry(result.registry);
return;
}
pendingProjectPlugins = catalog.plugins.filter((entry) =>
result.pending.some((pending) => pending.pluginId === entry.id),
);
const previousRegistry = activeRegistry;
activeRegistry = result.registry;
setValue({
@ -133,16 +148,35 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
}
}
function focusProject(projectId: string | undefined, initial = false): void {
if (projectId === currentProjectId && refreshRevision > 0) return;
currentProjectId = projectId;
clearRuntimeForProjectChange();
void refreshRuntime(initial);
}
void gateways.focusedProject
.onFocusedProjectChanged((project) => {
if (!project || pendingProjectPlugins.length === 0) return;
void refreshRuntime(false);
focusRevision += 1;
focusProject(project?.id);
})
.then((unsubscribe) => {
if (cancelled) unsubscribe();
else unsubscribeFocus = unsubscribe;
});
const initialFocusRevision = focusRevision;
void gateways.focusedProject
.getFocusedProject()
.then((project) => {
if (!cancelled && focusRevision === initialFocusRevision) {
focusProject(project?.id, true);
}
})
.catch(() => {
if (!cancelled && focusRevision === initialFocusRevision) focusProject(undefined, true);
});
void gateways.system
.onDomainEvent((event) => {
if (event.type === "pluginInstalled" || event.type === "pluginReloaded") {
@ -154,7 +188,6 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
else unsubscribeDomainEvents = unsubscribe;
});
void refreshRuntime(true);
return () => {
cancelled = true;
refreshRevision += 1;

View File

@ -3,7 +3,7 @@
*
* At UI bootstrap and after an install/reload event, the app calls
* {@link loadPlugins} with the catalog
* from `PluginGateway.listRuntimeContributions()` (already filtered by the
* from `PluginGateway.listRuntimeContributions(projectId)` (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
* the bundle URL, validates the module shape, and calls `activate(ctx)`,

View File

@ -1704,7 +1704,7 @@ export interface PluginGateway {
installFromDirectory(path: string): Promise<PluginInstallResult>;
setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin>;
uninstall(pluginId: string): Promise<PluginUninstallResult>;
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog>;
listRuntimeContributions(projectId?: string): Promise<PluginRuntimeContributionCatalog>;
listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView>;
setProjectEnabled(input: SetProjectPluginEnabledInput): Promise<ProjectPluginSettings>;
assignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings>;