From bf33e585083345fbe8b4e66f7a00f2c2c609f20b Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 11 Sep 2026 17:46:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(application,backend,infrastructure,app-tau?= =?UTF-8?q?ri,frontend):=20contributions=20UI/runtime=20des=20plugins=20li?= =?UTF-8?q?mit=C3=A9es=20aux=20projets=20li=C3=A9s=20=E2=80=94=20#321=20(Q?= =?UTF-8?q?A=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/app-tauri/src/commands.rs | 14 +- crates/app-tauri/src/plugins.rs | 6 +- crates/application/src/plugin/mod.rs | 184 +++++++++++++++--- crates/backend/src/dto.rs | 28 +++ crates/backend/src/lib.rs | 2 + .../tests/plugin_install_load.rs | 158 ++++++++++++--- frontend/src/adapters/http/unsupported.ts | 2 +- frontend/src/adapters/mock/index.ts | 2 +- frontend/src/adapters/plugin.test.ts | 10 + frontend/src/adapters/plugin.ts | 6 +- .../plugins/PluginLayoutCellView.test.tsx | 21 +- .../features/plugins/PluginLayoutCellView.tsx | 24 ++- .../plugins/PluginRuntimeProvider.test.tsx | 115 ++++++++++- .../plugins/PluginRuntimeProvider.tsx | 57 ++++-- frontend/src/plugins/runtime/loader.ts | 2 +- frontend/src/ports/index.ts | 2 +- 16 files changed, 523 insertions(+), 110 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 506333c9..2c45b2b5 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -125,9 +125,14 @@ pub async fn list_slash_commands( state: State<'_, AppState>, ) -> Result { let request = request.unwrap_or_default(); + let project_id = request + .project_id + .as_deref() + .map(parse_project_id) + .transpose()?; let catalog = state .list_plugin_runtime_contributions - .execute() + .execute(project_id) .await .map_err(ErrorDto::from)?; let plugin_commands = @@ -153,6 +158,11 @@ pub async fn execute_slash_command( request: ExecuteSlashCommandRequestDto, state: State<'_, AppState>, ) -> Result { + let project_id = request + .project_id + .as_deref() + .map(parse_project_id) + .transpose()?; let session_id = request .session_id .as_deref() @@ -160,7 +170,7 @@ pub async fn execute_slash_command( .transpose()?; let catalog = state .list_plugin_runtime_contributions - .execute() + .execute(project_id) .await .map_err(ErrorDto::from)?; let plugin_commands = diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index 375887e3..23ae8b2b 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -283,11 +283,15 @@ pub async fn plugin_uninstall( /// Lists active runtime contributions for frontend bootstrap. #[tauri::command] pub async fn plugin_list_runtime_contributions( + project_id: Option, state: State<'_, AppState>, ) -> Result { + let project_id = project_id + .map(|project_id| parse_project_id(&project_id)) + .transpose()?; state .list_plugin_runtime_contributions - .execute() + .execute(project_id) .await .map(PluginRuntimeContributionCatalogDto::from) .map_err(ErrorDto::from) diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 921c2145..ee9908a7 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -15,8 +15,8 @@ use domain::{ AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash, DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource, PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerSpec, - PluginRegistryEntry, PluginTrustLevel, Project, ProjectId, ProjectPath, RemovalOutcome, - StagedPluginPackage, TaskId, + PluginRegistryEntry, PluginTrustLevel, Project, ProjectId, ProjectPath, + ProjectPluginSettingsStore, RemovalOutcome, StagedPluginPackage, TaskId, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -2948,6 +2948,8 @@ pub struct ListPluginRuntimeContributions { packages: Arc, registry: Arc, validator: Arc, + projects: Arc, + project_settings: Arc, } impl ListPluginRuntimeContributions { @@ -2957,21 +2959,45 @@ impl ListPluginRuntimeContributions { packages: Arc, registry: Arc, validator: Arc, + projects: Arc, + project_settings: Arc, ) -> Self { Self { packages, registry, validator, + projects, + project_settings, } } /// Executes the use case. - pub async fn execute(&self) -> Result { + /// + /// Without a project scope, no project-visible contribution is exposed. + pub async fn execute( + &self, + project_id: Option, + ) -> Result { + let Some(project_id) = project_id else { + return Ok(PluginRuntimeCatalog { + plugins: Vec::new(), + }); + }; + let project = self + .projects + .load_project(project_id) + .await + .map_err(AppError::from)?; + let settings = self + .project_settings + .load(&project.root) + .await + .map_err(AppError::from)?; let mut registry = self.registry.load_registry().await.map_err(map_registry)?; let mut plugins = Vec::new(); let mut invalid_plugins = Vec::new(); for entry in registry.plugins.clone() { - if !entry.lifecycle_state.is_runtime_active() { + if !entry.lifecycle_state.is_runtime_active() || !settings.is_enabled(&entry.id) { continue; } match runtime_plugin_from_entry( @@ -3797,6 +3823,60 @@ mod tests { PluginId::new("dev.acme.gitgraph").unwrap() } + fn runtime_project_id() -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(0x321)) + } + + fn runtime_project() -> Project { + Project::new( + runtime_project_id(), + "Runtime project", + ProjectPath::new("/runtime-project").unwrap(), + RemoteRef::Local, + 0, + ) + .unwrap() + } + + fn runtime_usecase( + packages: Arc, + registry: Arc, + validator: Arc, + ) -> ListPluginRuntimeContributions { + runtime_usecase_with_settings( + packages, + registry, + validator, + domain::ProjectPluginSettings { + plugins: vec![domain::ProjectPluginActivation { + plugin_id: plugin_id(), + enabled: true, + }], + ..domain::ProjectPluginSettings::default() + }, + ) + } + + fn runtime_usecase_with_settings( + packages: Arc, + registry: Arc, + validator: Arc, + settings: domain::ProjectPluginSettings, + ) -> ListPluginRuntimeContributions { + let project = runtime_project(); + let projects = Arc::new(FakeProjectStore { + projects: Mutex::new(HashMap::from([(project.id, project)])), + }); + let project_settings = Arc::new(FakeRuntimeProjectSettings { settings }); + ListPluginRuntimeContributions::new( + packages, + registry, + validator, + projects, + project_settings, + ) + } + fn content_hash(raw: &str) -> ContentHash { ContentHash::new(raw).unwrap() } @@ -4296,25 +4376,61 @@ mod tests { let registry = Arc::new(FakeRegistry { registry: Mutex::new(registry_with(state)), }); - let usecase = - ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator())); + let usecase = runtime_usecase(packages, registry, Arc::new(validator())); - let catalog = usecase.execute().await.unwrap(); + let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap(); assert!(catalog.plugins.is_empty(), "{state:?} must not be active"); } } + #[tokio::test] + async fn runtime_catalog_without_project_scope_exposes_no_contributions() { + let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), + }); + let usecase = runtime_usecase(packages, registry, Arc::new(validator())); + + let catalog = usecase.execute(None).await.unwrap(); + + assert!(catalog.plugins.is_empty()); + } + + #[tokio::test] + async fn runtime_catalog_requires_explicit_project_activation_not_agent_assignment() { + let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), + }); + let settings = domain::ProjectPluginSettings { + agents: vec![domain::AgentPluginAssignments { + agent_id: AgentId::from_uuid(Uuid::from_u128(7)), + skills: vec![domain::PluginSkillRef { + plugin_id: plugin_id(), + skill_id: domain::PluginSkillId::new("build-debug").unwrap(), + }], + tools: Vec::new(), + }], + ..domain::ProjectPluginSettings::default() + }; + let usecase = + runtime_usecase_with_settings(packages, registry, Arc::new(validator()), settings); + + let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap(); + + assert!(catalog.plugins.is_empty()); + } + #[tokio::test] async fn runtime_catalog_carries_manifest_capabilities() { let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); let registry = Arc::new(FakeRegistry { registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), }); - let usecase = - ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator())); + let usecase = runtime_usecase(packages, registry, Arc::new(validator())); - let catalog = usecase.execute().await.unwrap(); + let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap(); assert_eq!(catalog.plugins.len(), 1); assert_eq!( @@ -4338,10 +4454,9 @@ mod tests { let registry = Arc::new(FakeRegistry { registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), }); - let usecase = - ListPluginRuntimeContributions::new(packages, registry.clone(), Arc::new(validator())); + let usecase = runtime_usecase(packages, registry.clone(), Arc::new(validator())); - let catalog = usecase.execute().await.unwrap(); + let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap(); assert!( catalog.plugins.is_empty(), @@ -4673,11 +4788,10 @@ mod tests { } ))); - let runtime = - ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator())) - .execute() - .await - .unwrap(); + let runtime = runtime_usecase(packages, registry, Arc::new(validator())) + .execute(Some(runtime_project_id())) + .await + .unwrap(); assert!(runtime.plugins.is_empty()); } @@ -4838,14 +4952,10 @@ mod tests { assert!(registry.load_registry().await.unwrap().plugins.is_empty()); let result = install.execute("/source/plugin".to_owned()).await.unwrap(); - let runtime = ListPluginRuntimeContributions::new( - packages.clone(), - registry.clone(), - Arc::new(validator()), - ) - .execute() - .await - .unwrap(); + let runtime = runtime_usecase(packages.clone(), registry.clone(), Arc::new(validator())) + .execute(Some(runtime_project_id())) + .await + .unwrap(); assert_eq!(result.plugin.lifecycle_state, PluginLifecycleState::Enabled); assert_eq!(runtime.plugins.len(), 1); @@ -4957,6 +5067,28 @@ mod tests { } } + struct FakeRuntimeProjectSettings { + settings: domain::ProjectPluginSettings, + } + + #[async_trait::async_trait] + impl ProjectPluginSettingsStore for FakeRuntimeProjectSettings { + async fn load( + &self, + _project_root: &ProjectPath, + ) -> Result { + Ok(self.settings.clone()) + } + + async fn save( + &self, + _project_root: &ProjectPath, + _settings: &domain::ProjectPluginSettings, + ) -> Result<(), StoreError> { + Ok(()) + } + } + #[derive(Default)] struct FakeWorkspaceFs { files: Mutex>>, diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index bede122c..83ad2bab 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -917,6 +917,9 @@ impl From for HealthResponseDto { #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListSlashCommandsRequestDto { + /// Project whose explicitly enabled plugin contributions may be listed. + #[serde(default)] + pub project_id: Option, /// Optional typed prefix, with or without the leading `/`. #[serde(default)] pub prefix: Option, @@ -945,6 +948,9 @@ impl From for SlashCommandListDto { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecuteSlashCommandRequestDto { + /// Project whose explicitly enabled plugin contribution may be executed. + #[serde(default)] + pub project_id: Option, /// Command name, with or without the leading `/`. pub name: String, /// Current structured chat session id, required by `/clean` and future @@ -6251,6 +6257,28 @@ mod tests { use serde_json::json; use uuid::Uuid; + #[test] + fn slash_command_requests_accept_optional_project_scope() { + let project_id = Uuid::from_u128(321).to_string(); + let list: ListSlashCommandsRequestDto = serde_json::from_value(json!({ + "projectId": project_id, + "prefix": "/graph" + })) + .unwrap(); + assert_eq!(list.project_id.as_deref(), Some(project_id.as_str())); + + let execute: ExecuteSlashCommandRequestDto = serde_json::from_value(json!({ + "projectId": project_id, + "name": "/graph", + "arguments": [] + })) + .unwrap(); + assert_eq!(execute.project_id.as_deref(), Some(project_id.as_str())); + + let unscoped: ListSlashCommandsRequestDto = serde_json::from_value(json!({})).unwrap(); + assert!(unscoped.project_id.is_none()); + } + use super::*; #[test] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index b28bb08f..ac34dcde 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -3831,6 +3831,8 @@ impl BackendCore { Arc::clone(&plugin_package_store), Arc::clone(&plugin_registry_store), Arc::clone(&plugin_manifest_validator), + Arc::clone(&store_port), + Arc::clone(&project_plugin_settings_store), )); let reconcile_plugin_mcp_servers = Arc::new(ReconcilePluginMcpServers::new( Arc::clone(&plugin_package_store), diff --git a/crates/infrastructure/tests/plugin_install_load.rs b/crates/infrastructure/tests/plugin_install_load.rs index e4ca5fcc..e8a9e8c2 100644 --- a/crates/infrastructure/tests/plugin_install_load.rs +++ b/crates/infrastructure/tests/plugin_install_load.rs @@ -7,6 +7,12 @@ use application::{ InstallPluginFromDirectory, JsonPluginManifestValidator, ListPluginRuntimeContributions, ListPlugins, UninstallPlugin, UninstallPluginInput, }; +use async_trait::async_trait; +use domain::{ + PluginId, PluginManifestValidator, PluginPackageStore, PluginRegistryStore, Project, ProjectId, + ProjectPath, ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore, + ProjectStore, RemoteRef, StoreError, +}; use infrastructure::{ ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, TokioBroadcastEventBus, @@ -14,6 +20,88 @@ use infrastructure::{ static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); +struct RuntimeProjectStore { + project: Project, +} + +#[async_trait] +impl ProjectStore for RuntimeProjectStore { + async fn list_projects(&self) -> Result, StoreError> { + Ok(vec![self.project.clone()]) + } + + async fn load_project(&self, id: ProjectId) -> Result { + (id == self.project.id) + .then(|| self.project.clone()) + .ok_or(StoreError::NotFound) + } + + async fn save_project(&self, _project: &Project) -> Result<(), StoreError> { + Ok(()) + } + + async fn save_workspace(&self, _workspace: &domain::Workspace) -> Result<(), StoreError> { + Ok(()) + } + + async fn load_workspace(&self) -> Result { + Ok(domain::Workspace::default()) + } +} + +struct RuntimeProjectSettings { + settings: ProjectPluginSettings, +} + +#[async_trait] +impl ProjectPluginSettingsStore for RuntimeProjectSettings { + async fn load(&self, _project_root: &ProjectPath) -> Result { + Ok(self.settings.clone()) + } + + async fn save( + &self, + _project_root: &ProjectPath, + _settings: &ProjectPluginSettings, + ) -> Result<(), StoreError> { + Ok(()) + } +} + +fn runtime_catalog_usecase( + packages: Arc, + registry: Arc, + validator: Arc, + plugin_id: &str, +) -> (ListPluginRuntimeContributions, ProjectId) { + let project_id = ProjectId::new_random(); + let project = Project::new( + project_id, + "Runtime project", + ProjectPath::new("/runtime-project").unwrap(), + RemoteRef::Local, + 0, + ) + .unwrap(); + let settings = ProjectPluginSettings { + plugins: vec![ProjectPluginActivation { + plugin_id: PluginId::new(plugin_id).unwrap(), + enabled: true, + }], + ..ProjectPluginSettings::default() + }; + ( + ListPluginRuntimeContributions::new( + packages, + registry, + validator, + Arc::new(RuntimeProjectStore { project }), + Arc::new(RuntimeProjectSettings { settings }), + ), + project_id, + ) +} + fn temp_dir(label: &str) -> PathBuf { let n = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst); let path = std::env::temp_dir().join(format!( @@ -124,10 +212,13 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() { domain::PluginLifecycleState::Enabled ); - let catalog = ListPluginRuntimeContributions::new(packages, registry, validator) - .execute() - .await - .unwrap(); + let (runtime, project_id) = runtime_catalog_usecase( + packages, + registry, + validator, + "dev.idea.fixtures.reference-minimal", + ); + let catalog = runtime.execute(Some(project_id)).await.unwrap(); assert_eq!(catalog.plugins.len(), 1); let plugin = &catalog.plugins[0]; assert_eq!(plugin.id, "dev.idea.fixtures.reference-minimal"); @@ -179,10 +270,9 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() { .join("plugins/installed/com.example.hello-plugin/dist/index.js") .is_file()); - let catalog = ListPluginRuntimeContributions::new(packages, registry, validator) - .execute() - .await - .unwrap(); + let (runtime, project_id) = + runtime_catalog_usecase(packages, registry, validator, "com.example.hello-plugin"); + let catalog = runtime.execute(Some(project_id)).await.unwrap(); assert_eq!(catalog.plugins.len(), 1); let plugin = &catalog.plugins[0]; assert_eq!(plugin.id, "com.example.hello-plugin"); @@ -261,16 +351,18 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() { .unwrap() .is_empty() ); - assert!(ListPluginRuntimeContributions::new( + let (runtime, project_id) = runtime_catalog_usecase( packages.clone(), registry.clone(), - validator.clone() - ) - .execute() - .await - .unwrap() - .plugins - .is_empty()); + validator.clone(), + "com.example.hello-plugin", + ); + assert!(runtime + .execute(Some(project_id)) + .await + .unwrap() + .plugins + .is_empty()); let reinstall = install .execute(hello_plugin.to_string_lossy().into_owned()) @@ -281,10 +373,9 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() { reinstall.plugin.lifecycle_state, domain::PluginLifecycleState::Enabled ); - let catalog = ListPluginRuntimeContributions::new(packages, registry, validator) - .execute() - .await - .unwrap(); + let (runtime, project_id) = + runtime_catalog_usecase(packages, registry, validator, "com.example.hello-plugin"); + let catalog = runtime.execute(Some(project_id)).await.unwrap(); assert_eq!(catalog.plugins.len(), 1); assert_eq!(catalog.plugins[0].id, "com.example.hello-plugin"); assert!(catalog.plugins[0].bundle_url.ends_with("/dist/index.js")); @@ -349,14 +440,14 @@ async fn uninstall_multifile_plugin_removes_package_registry_and_runtime_residue .unwrap() .is_empty() ); - assert!( - ListPluginRuntimeContributions::new(packages, registry, validator) - .execute() - .await - .unwrap() - .plugins - .is_empty() - ); + let (runtime, project_id) = + runtime_catalog_usecase(packages, registry, validator, "dev.acme.multifile"); + assert!(runtime + .execute(Some(project_id)) + .await + .unwrap() + .plugins + .is_empty()); let _ = fs::remove_dir_all(app_data); let _ = fs::remove_dir_all(source); @@ -409,10 +500,13 @@ async fn installed_plugin_with_missing_main_is_isolated_from_runtime_catalog() { .unwrap_or_default() .contains("not servable")); - let catalog = ListPluginRuntimeContributions::new(packages, registry.clone(), validator) - .execute() - .await - .unwrap(); + let (runtime, project_id) = runtime_catalog_usecase( + packages, + registry.clone(), + validator, + "dev.idea.fixtures.missing-main", + ); + let catalog = runtime.execute(Some(project_id)).await.unwrap(); assert!(catalog.plugins.is_empty()); let admin = ListPlugins::new( Arc::new(FsPluginPackageStore::new(&app_data)), diff --git a/frontend/src/adapters/http/unsupported.ts b/frontend/src/adapters/http/unsupported.ts index 9259c47e..d816ece8 100644 --- a/frontend/src/adapters/http/unsupported.ts +++ b/frontend/src/adapters/http/unsupported.ts @@ -191,7 +191,7 @@ export class WebPluginGateway implements PluginGateway { async uninstall(_pluginId: string): Promise { return unsupportedOnWeb("Plugin management"); } - async listRuntimeContributions(): Promise { + async listRuntimeContributions(_projectId?: string): Promise { // 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: [] }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 7ad51936..d7235ae8 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -4424,7 +4424,7 @@ export class MockPluginGateway implements PluginGateway { return { pluginId, removalOutcome: "removed", restartRequired: true }; } - async listRuntimeContributions(): Promise { + async listRuntimeContributions(_projectId?: string): Promise { // 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` diff --git a/frontend/src/adapters/plugin.test.ts b/frontend/src/adapters/plugin.test.ts index fdf0e3f2..3a672dbc 100644 --- a/frontend/src/adapters/plugin.test.ts +++ b/frontend/src/adapters/plugin.test.ts @@ -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", + }); + }); }); diff --git a/frontend/src/adapters/plugin.ts b/frontend/src/adapters/plugin.ts index 0bb27cdb..3eeacf8d 100644 --- a/frontend/src/adapters/plugin.ts +++ b/frontend/src/adapters/plugin.ts @@ -70,8 +70,10 @@ export class TauriPluginGateway implements PluginGateway { return invoke("plugin_uninstall", { pluginId }); } - listRuntimeContributions(): Promise { - return invoke("plugin_list_runtime_contributions"); + listRuntimeContributions(projectId?: string): Promise { + return invoke("plugin_list_runtime_contributions", { + projectId: projectId ?? null, + }); } listProjectSettings(projectId: string): Promise { diff --git a/frontend/src/features/plugins/PluginLayoutCellView.test.tsx b/frontend/src/features/plugins/PluginLayoutCellView.test.tsx index c959f208..863bb994 100644 --- a/frontend/src/features/plugins/PluginLayoutCellView.test.tsx +++ b/frontend/src/features/plugins/PluginLayoutCellView.test.tsx @@ -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 ?? (() => {})} /> , @@ -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()); }); diff --git a/frontend/src/features/plugins/PluginLayoutCellView.tsx b/frontend/src/features/plugins/PluginLayoutCellView.tsx index ace41582..4ff946b3 100644 --- a/frontend/src/features/plugins/PluginLayoutCellView.tsx +++ b/frontend/src/features/plugins/PluginLayoutCellView.tsx @@ -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 ( - - ); - } + 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 = ( diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx index 16fc313b..82b75777 100644 --- a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx +++ b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx @@ -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 = {}): PluginRunt class MutableRuntimeCatalogGateway extends MockPluginGateway { calls = 0; catalog: PluginRuntimeContributionCatalog = { plugins: [] }; + catalogs = new Map(); + projectIds: Array = []; - async listRuntimeContributions(): Promise { + async listRuntimeContributions(projectId?: string): Promise { 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 { + 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} /> , @@ -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( + + + {}} /> + + , + ); + + 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( + + + {}} /> + + , + ); + + 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(); + }); }); diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.tsx index 4ef66fbc..883c6c02 100644 --- a/frontend/src/features/plugins/PluginRuntimeProvider.tsx +++ b/frontend/src/features/plugins/PluginRuntimeProvider.tsx @@ -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 { 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 { 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; diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 839b90e1..703952b0 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -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)`, diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 2d76a2ab..72c8c152 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -1704,7 +1704,7 @@ export interface PluginGateway { installFromDirectory(path: string): Promise; setEnabled(pluginId: string, enabled: boolean): Promise; uninstall(pluginId: string): Promise; - listRuntimeContributions(): Promise; + listRuntimeContributions(projectId?: string): Promise; listProjectSettings(projectId: string): Promise; setProjectEnabled(input: SetProjectPluginEnabledInput): Promise; assignAgentSkill(input: MutateAgentPluginSkillInput): Promise;