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:
@ -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]
|
||||
|
||||
Reference in New Issue
Block a user