feat(domain,application,backend,app-tauri,frontend): lot utilisateur #328 #329 #330 #331 — section plugins projet, erreurs menus plugin visibles, refresh auto des pages, droplist agents (QA verte)

Lot en un seul commit: les quatre tickets partagent les mêmes fichiers
(événements domaine/DTO, ProjectPluginsPanel, useProjectPlugins) — une
séparation par hunks aurait risqué des états intermédiaires non
compilables (convention lot des batches #310-#313, #315 et #325-#326).

#328 — section dédiée plugins dans Paramètres du projet:
- frontend: ProjectPluginsPanel intégré à ProjectSettingsPanel
  (Panneaux > Paramètres du projet) — activation/désactivation par
  plugin avec état actif/inactif explicite par projet, distinct de
  l'assignation agents/skills/tools
- domaine: événement ProjectPluginSettingsChanged publié par
  SetProjectPluginEnabled, MutateAgentPluginSkill et
  MutateAgentPluginTool après persistance de .ideai/project-plugins.json
  (application + backend + DTO camelCase, test dto.rs)

#329 — les menus plugin qui échouent ne sont plus muets:
- usePluginMenus: dispatcher strict (runCommandStrict) pour les
  commandes réellement enregistrées au lieu du dispatcher laxiste qui
  journalisait et avalait les rejets — rendait les échecs d'ouverture
  de fenêtres natifs invisibles pour l'hôte de menus
- le no-op SDK historique (commande déclarée sans handler) est préservé
- échec remonté à l'hôte via commandError/clearCommandError (journal
  console + état exposé); test window_usecases adapté à un plugin
  installé
- le correctif côté SDK/GitPlugin vit dans le projet GitPlugin (son
  commit/merge local y est fait, hors périmètre de ce dépôt)

#330 — refresh automatique des pages:
- frontend/domain: typage des événements pluginEnabled,
  pluginDisabled (restartRequired), pluginUninstalled
  (restartRequired), pluginLoadFailed et projectPluginSettingsChanged
- useProjectPlugins et PluginRuntimeProvider: souscription
  onDomainEvent — refresh sur changement catalogue global ou sur
  projectPluginSettingsChanged du projet courant; gardes
  révision/projet contre les réponses obsolètes
- useProjectAgents: refresh auto quand un agent est ajouté au projet;
  ProjectsView et AgentsPanel suivent (erreur de rafraîchissement
  affichée avec bouton Réessayer)

#331 — droplist agents des cellules:
- SmallDropdown: liste bg-overlay + bordure border-strong (plus de
  fond blanc hors thème), hover/focus bg-raised, sélection
  bg-primary/15, disabled text-faint opacity-60, role=combobox
- LayoutGrid: sélecteur d'agent des cellules migré du <select> natif
  vers SmallDropdown (mêmes libellés Terminal/visible
  ailleurs/arrière-plan)

QA verte réelle sur les quatre lots (tests ciblés + exécution).
sdk/IdeaSDK: contenu modifié non committable ici (pointeur inchangé).
Aucun push.
This commit is contained in:
2026-09-12 01:14:45 +02:00
parent 4f0457c08a
commit db67ecdf8f
24 changed files with 706 additions and 146 deletions

View File

@ -4,10 +4,11 @@ use std::collections::HashSet;
use std::sync::Arc;
use domain::{
AgentContextStore, AgentId, AgentPluginAssignments, MarkdownDoc, PluginId, PluginManifest,
PluginManifestValidator, PluginMcpServerId, PluginMcpServerSpec, PluginPackageStore,
PluginRegistryStore, PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore,
AgentContextStore, AgentId, AgentPluginAssignments, DomainEvent, EventBus, MarkdownDoc,
PluginId, PluginManifest, PluginManifestValidator, PluginMcpServerId, PluginMcpServerSpec,
PluginPackageStore, PluginRegistryStore, PluginSkillId, PluginSkillRef, PluginStoreError,
PluginToolRef, Project, ProjectPluginActivation, ProjectPluginSettings,
ProjectPluginSettingsStore,
};
use serde::{Deserialize, Serialize};
@ -292,6 +293,7 @@ impl ListProjectPluginSettings {
pub struct SetProjectPluginEnabled {
store: Arc<dyn ProjectPluginSettingsStore>,
packages: Arc<dyn PluginPackageStore>,
events: Arc<dyn EventBus>,
}
impl SetProjectPluginEnabled {
@ -300,8 +302,13 @@ impl SetProjectPluginEnabled {
pub fn new(
store: Arc<dyn ProjectPluginSettingsStore>,
packages: Arc<dyn PluginPackageStore>,
events: Arc<dyn EventBus>,
) -> Self {
Self { store, packages }
Self {
store,
packages,
events,
}
}
/// Persists activation. Enabling requires a currently installed plugin;
@ -336,6 +343,10 @@ impl SetProjectPluginEnabled {
.push(ProjectPluginActivation { plugin_id, enabled }),
}
self.store.save(&project.root, &settings).await?;
self.events
.publish(DomainEvent::ProjectPluginSettingsChanged {
project_id: project.id,
});
Ok(settings)
}
}
@ -354,6 +365,7 @@ pub struct MutateAgentPluginSkill {
store: Arc<dyn ProjectPluginSettingsStore>,
contexts: Arc<dyn AgentContextStore>,
resolver: Arc<ProjectPluginResolver>,
events: Arc<dyn EventBus>,
}
impl MutateAgentPluginSkill {
@ -363,11 +375,13 @@ impl MutateAgentPluginSkill {
store: Arc<dyn ProjectPluginSettingsStore>,
contexts: Arc<dyn AgentContextStore>,
resolver: Arc<ProjectPluginResolver>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
store,
contexts,
resolver,
events,
}
}
@ -412,6 +426,10 @@ impl MutateAgentPluginSkill {
_ => {}
}
self.store.save(&project.root, &settings).await?;
self.events
.publish(DomainEvent::ProjectPluginSettingsChanged {
project_id: project.id,
});
Ok(settings)
}
}
@ -421,6 +439,7 @@ pub struct MutateAgentPluginTool {
store: Arc<dyn ProjectPluginSettingsStore>,
contexts: Arc<dyn AgentContextStore>,
resolver: Arc<ProjectPluginResolver>,
events: Arc<dyn EventBus>,
}
impl MutateAgentPluginTool {
@ -430,11 +449,13 @@ impl MutateAgentPluginTool {
store: Arc<dyn ProjectPluginSettingsStore>,
contexts: Arc<dyn AgentContextStore>,
resolver: Arc<ProjectPluginResolver>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
store,
contexts,
resolver,
events,
}
}
@ -483,6 +504,10 @@ impl MutateAgentPluginTool {
_ => {}
}
self.store.save(&project.root, &settings).await?;
self.events
.publish(DomainEvent::ProjectPluginSettingsChanged {
project_id: project.id,
});
Ok(settings)
}
}
@ -550,16 +575,82 @@ mod tests {
use super::*;
use async_trait::async_trait;
use domain::{
ContentHash, LocalPath, PluginActivationScope, PluginCapability, PluginContributionSet,
PluginInstallSource, PluginLifecycleState, PluginManifestBytes, PluginManifestError,
PluginMcpServerContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
PluginTrustLevel, PluginVersion, ProjectPath, RelativePath, RemovalOutcome,
StagedPluginPackage,
Agent, AgentManifest, AgentOrigin, ContentHash, LocalPath, PluginActivationScope,
PluginCapability, PluginContributionSet, PluginInstallSource, PluginLifecycleState,
PluginManifestBytes, PluginManifestError, PluginMcpServerContribution, PluginPackageRef,
PluginRegistry, PluginRegistryEntry, PluginTrustLevel, PluginVersion, ProfileId,
ProjectPath, RelativePath, RemoteRef, RemovalOutcome, StagedPluginPackage,
};
use std::sync::Mutex;
struct MemorySettings {
settings: Mutex<ProjectPluginSettings>,
fail_save: bool,
}
#[derive(Clone, Default)]
struct SpyEvents(Arc<Mutex<Vec<DomainEvent>>>);
impl SpyEvents {
fn events(&self) -> Vec<DomainEvent> {
self.0.lock().unwrap().clone()
}
}
impl EventBus for SpyEvents {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> domain::EventStream {
Box::new(std::iter::empty())
}
}
struct FakeContexts;
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
_agent: &AgentId,
) -> Result<MarkdownDoc, domain::StoreError> {
Ok(MarkdownDoc::new("# Agent"))
}
async fn write_context(
&self,
_project: &Project,
_agent: &AgentId,
_md: &MarkdownDoc,
) -> Result<(), domain::StoreError> {
Ok(())
}
async fn load_manifest(
&self,
_project: &Project,
) -> Result<AgentManifest, domain::StoreError> {
let agent = Agent::new(
agent_id(),
"Dev",
"agents/dev.md",
ProfileId::from_uuid(uuid::Uuid::from_u128(2)),
AgentOrigin::Scratch,
false,
)
.unwrap();
Ok(AgentManifest::new(1, vec![domain::ManifestEntry::from_agent(&agent)]).unwrap())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), domain::StoreError> {
Ok(())
}
}
#[async_trait]
@ -576,6 +667,9 @@ mod tests {
_project_root: &ProjectPath,
settings: &ProjectPluginSettings,
) -> Result<(), domain::StoreError> {
if self.fail_save {
return Err(domain::StoreError::Io("save failed".to_owned()));
}
*self.settings.lock().unwrap() = settings.clone();
Ok(())
}
@ -684,6 +778,17 @@ mod tests {
ProjectPath::new("/project").unwrap()
}
fn project() -> Project {
Project::new(
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(328)),
"Plugin project",
project_root(),
RemoteRef::Local,
0,
)
.unwrap()
}
fn manifest(auto_start: bool) -> PluginManifest {
PluginManifest {
idea_plugin_manifest_version: 1,
@ -769,6 +874,7 @@ mod tests {
ProjectPluginResolver::new(
Arc::new(MemorySettings {
settings: Mutex::new(settings),
fail_save: false,
}),
Arc::new(MemoryPackages {
packages: vec![PluginPackageRef {
@ -869,4 +975,111 @@ mod tests {
.unwrap()
.is_empty());
}
#[tokio::test]
async fn project_plugin_activation_publishes_change_after_successful_save() {
let store = Arc::new(MemorySettings {
settings: Mutex::new(ProjectPluginSettings::default()),
fail_save: false,
});
let packages = Arc::new(MemoryPackages {
packages: vec![PluginPackageRef {
plugin_id: Some(plugin_id()),
root: "/plugins/unity".to_owned(),
}],
app_data_dir: None,
});
let events = SpyEvents::default();
let use_case = SetProjectPluginEnabled::new(store, packages, Arc::new(events.clone()));
let project = project();
use_case
.execute(project.clone(), plugin_id(), true)
.await
.unwrap();
assert_eq!(
events.events(),
vec![DomainEvent::ProjectPluginSettingsChanged {
project_id: project.id,
}]
);
}
#[tokio::test]
async fn project_plugin_activation_does_not_publish_when_save_fails() {
let store = Arc::new(MemorySettings {
settings: Mutex::new(ProjectPluginSettings::default()),
fail_save: true,
});
let packages = Arc::new(MemoryPackages {
packages: vec![PluginPackageRef {
plugin_id: Some(plugin_id()),
root: "/plugins/unity".to_owned(),
}],
app_data_dir: None,
});
let events = SpyEvents::default();
let use_case = SetProjectPluginEnabled::new(store, packages, Arc::new(events.clone()));
let error = use_case
.execute(project(), plugin_id(), true)
.await
.unwrap_err();
assert_eq!(error.code(), "STORE");
assert!(events.events().is_empty());
}
#[tokio::test]
async fn project_plugin_assignment_publishes_change_after_successful_save() {
let store = Arc::new(MemorySettings {
settings: Mutex::new(settings(true, Vec::new())),
fail_save: false,
});
let packages = Arc::new(MemoryPackages {
packages: vec![PluginPackageRef {
plugin_id: Some(plugin_id()),
root: "/plugins/unity".to_owned(),
}],
app_data_dir: None,
});
let resolver = Arc::new(ProjectPluginResolver::new(
store.clone(),
packages,
Arc::new(MemoryRegistry {
registry: registry(PluginLifecycleState::Enabled),
}),
Arc::new(FixedManifest(manifest(true))),
));
let events = SpyEvents::default();
let use_case = MutateAgentPluginTool::new(
store,
Arc::new(FakeContexts),
resolver,
Arc::new(events.clone()),
);
let project = project();
use_case
.execute(
project.clone(),
agent_id(),
PluginToolRef {
plugin_id: plugin_id(),
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
tool_name: "unity_detect_project".to_owned(),
},
AssignmentMutation::Assign,
)
.await
.unwrap();
assert_eq!(
events.events(),
vec![DomainEvent::ProjectPluginSettingsChanged {
project_id: project.id,
}]
);
}
}