feat(plugins): seam slash-commands contribuées par plugin via callback — #165 (QA verte)

Permet à un plugin de contribuer des commandes slash adossées à une callback,
transitant par le registry/contrat unifié (#162) :

- sdk/IdeaSDK: manifeste contributes.slashCommands (déclaratif) — pointer bumpé.
- domain: source Plugin étendue + effet PluginCallback (la commande ne
  s'exécute pas directement : elle renvoie une référence à la callback du plugin).
- application: registration des commandes plugin dans le registry + exécution
  renvoyant l'effet PluginCallback ; métadonnées UI suffisantes pour l'autocomplete.
- backend + app-tauri: DTO + commandes Tauri listant/invoquant les commandes plugin.
- frontend (contrat): types adapters/domain/ports/mock pour pluginCallback et
  l'invocation avec arguments.

L'exécution effective de la callback côté runtime UI est livrée par #166.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:46:52 +02:00
parent ff7696b724
commit fbaca9d5cc
17 changed files with 514 additions and 34 deletions

View File

@ -31,6 +31,8 @@ pub struct PluginContributionSummary {
pub top_level_menus: usize,
/// Menu item count.
pub menu_items: usize,
/// Slash-command count.
pub slash_commands: usize,
/// Layout count.
pub layouts: usize,
/// MCP server count.
@ -42,6 +44,7 @@ impl From<&PluginContributionSet> for PluginContributionSummary {
Self {
top_level_menus: c.menus.len(),
menu_items: c.menu_items.len(),
slash_commands: c.slash_commands.len(),
layouts: c.layouts.len(),
mcp_servers: c.mcp_servers.len(),
}
@ -3088,6 +3091,8 @@ struct RawContributes {
#[serde(default)]
menu_items: Vec<RawMenuItem>,
#[serde(default)]
slash_commands: Vec<RawSlashCommand>,
#[serde(default)]
layouts: Vec<RawLayout>,
#[serde(default)]
mcp_servers: Vec<RawMcpServer>,
@ -3120,6 +3125,18 @@ struct RawMenuItem {
when: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawSlashCommand {
name: String,
short_description: String,
command: String,
#[serde(default)]
requires_confirmation: bool,
#[serde(default)]
when: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawLayout {
@ -3283,6 +3300,31 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
})
})
.collect::<Result<Vec<_>, _>>()?;
let slash_commands = raw
.slash_commands
.into_iter()
.map(|s| {
insert(&s.name)?;
if !s.name.starts_with('/') {
return Err(PluginManifestError::Invalid(
"slashCommands[].name must start with '/'".to_owned(),
));
}
if s.short_description.trim().is_empty() {
return Err(PluginManifestError::Invalid(
"slashCommands[].shortDescription is required".to_owned(),
));
}
Ok(domain::PluginSlashCommandContribution {
name: s.name,
short_description: s.short_description,
command: domain::PluginCommandId::new(s.command)
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
requires_confirmation: s.requires_confirmation,
when: s.when,
})
})
.collect::<Result<Vec<_>, _>>()?;
let layouts = raw
.layouts
.into_iter()
@ -3353,6 +3395,7 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
Ok(PluginContributionSet {
menus,
menu_items,
slash_commands,
layouts,
mcp_servers,
})
@ -3423,6 +3466,7 @@ mod tests {
"contributes": {
"menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}],
"menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}],
"slashCommands": [{"name":"/graph","shortDescription":"Open graph tools","command":"dev.acme.open","requiresConfirmation":true}],
"layouts": [{"type":"dev.acme.layout","label":"Graph","component":"Graph"}],
"mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true}]
}
@ -3745,6 +3789,9 @@ mod tests {
);
assert_eq!(m.contributes.layouts.len(), 1);
assert_eq!(m.contributes.mcp_servers.len(), 1);
assert_eq!(m.contributes.slash_commands.len(), 1);
assert_eq!(m.contributes.slash_commands[0].name, "/graph");
assert!(m.contributes.slash_commands[0].requires_confirmation);
}
#[test]
@ -3809,6 +3856,21 @@ mod tests {
.is_err());
}
#[test]
fn rejects_slash_command_without_leading_slash() {
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
value["contributes"]["slashCommands"][0]["name"] = serde_json::json!("graph");
assert!(validator()
.validate(
&serde_json::to_vec(&value).unwrap(),
&domain::PluginPackageRef {
plugin_id: None,
root: "x".into()
}
)
.is_err());
}
#[tokio::test]
async fn runtime_catalog_excludes_disabled_and_pending_uninstall_plugins() {
for state in [
@ -3849,6 +3911,7 @@ mod tests {
domain::PluginCapability::Tooling,
]
);
assert_eq!(catalog.plugins[0].contributes.slash_commands.len(), 1);
}
#[tokio::test]