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

@ -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<Vec<Project>, StoreError> {
Ok(vec![self.project.clone()])
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
(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<domain::Workspace, StoreError> {
Ok(domain::Workspace::default())
}
}
struct RuntimeProjectSettings {
settings: ProjectPluginSettings,
}
#[async_trait]
impl ProjectPluginSettingsStore for RuntimeProjectSettings {
async fn load(&self, _project_root: &ProjectPath) -> Result<ProjectPluginSettings, StoreError> {
Ok(self.settings.clone())
}
async fn save(
&self,
_project_root: &ProjectPath,
_settings: &ProjectPluginSettings,
) -> Result<(), StoreError> {
Ok(())
}
}
fn runtime_catalog_usecase(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
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)),