feat(plugins): open plugin workbench from context menu

This commit is contained in:
jearhe authored and GitHub committed 2026-09-24 19:14:53 +08:00
1 parent 05a72de255
commit 4c1eaa6b16
10 files changed
+494 -69

No files matched your search

+134 -16
View File
@@ -663,9 +663,9 @@ pub struct PluginWorkbenchContribution {
pub icon: Option<String>,
}
/// Native context-menu entry contributed to DBX surfaces. v1 targets the
/// saved-connection and table sidebar menus; clicks are dispatched to the
/// plugin backend as `contextMenu/<id>` requests.
/// Native context-menu entry contributed to DBX surfaces. Legacy entries
/// dispatch `contextMenu/<id>` to the plugin backend; declarative actions are
/// handled directly by the host.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginContextMenuContribution {
@@ -678,6 +678,24 @@ pub struct PluginContextMenuContribution {
/// Menu surface the item belongs to: `connection` or `table`.
#[serde(default)]
pub menu: String,
/// Optional host-handled action. When absent, the legacy backend entrypoint is required.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<PluginContextMenuAction>,
}
/// Actions that the host can perform directly for a context-menu contribution.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum PluginContextMenuAction {
OpenWorkbench(PluginContextMenuOpenWorkbenchAction),
}
/// Narrow context-menu form of the shared `open-workbench` target contract.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginContextMenuOpenWorkbenchAction {
/// Workbench contribution of the SAME plugin (dangling references are rejected during validation).
pub workbench: String,
}
/// Plugin-rendered visualization surface for query results. Selecting the view
@@ -1232,6 +1250,7 @@ fn validate_contributions(
let mut filesystem_provider_ids = HashSet::new();
let mut workbench_references = Vec::new();
let mut filesystem_references = Vec::new();
let mut context_menu_workbench_references = Vec::new();
let mut command_ids = HashSet::new();
let mut command_workbench_references = Vec::new();
let mut menu_command_references = Vec::new();
@@ -1320,7 +1339,10 @@ fn validate_contributions(
menu.menu
));
}
if !has_backend {
if let Some(PluginContextMenuAction::OpenWorkbench(action)) = &menu.action {
validate_optional_reference(Some(action.workbench.as_str()), "workbench", id, errors);
context_menu_workbench_references.push((id.to_string(), action.workbench.clone()));
} else if !has_backend {
errors.push(format!("Context menu contribution '{id}' requires a backend entrypoint"));
}
}
@@ -1454,6 +1476,11 @@ fn validate_contributions(
errors.push(format!("Menus contribution '{menus}' references missing command '{command}'"));
}
}
for (context_menu, workbench) in context_menu_workbench_references {
if !workbench_ids.contains(&workbench) {
errors.push(format!("Context menu '{context_menu}' references missing workbench '{workbench}'"));
}
}
for (command, workbench) in command_workbench_references {
if !workbench_ids.contains(&workbench) {
errors.push(format!("Command '{command}' references missing workbench '{workbench}'"));
@@ -1814,11 +1841,12 @@ mod tests {
SUPPORTED_PLUGIN_PERMISSIONS,
};
fn context_menu_manifest(menu: &str) -> (tempfile::TempDir, PluginManifest) {
let dir = tempfile::tempdir().unwrap();
let executable = dir.path().join("bin").join("example");
std::fs::create_dir_all(executable.parent().unwrap()).unwrap();
std::fs::write(&executable, b"example").unwrap();
fn context_menu_manifest(menu: &str) -> Result<(tempfile::TempDir, PluginManifest), Box<dyn std::error::Error>> {
let dir = tempfile::tempdir()?;
let binary_dir = dir.path().join("bin");
std::fs::create_dir_all(&binary_dir)?;
let executable = binary_dir.join("example");
std::fs::write(&executable, b"example")?;
let manifest = serde_json::from_value(serde_json::json!({
"manifest_version": 1,
"id": "io.dbx.example",
@@ -1833,9 +1861,8 @@ mod tests {
"label": "Inspect",
"menu": menu
}]
}))
.unwrap();
(dir, manifest)
}))?;
Ok((dir, manifest))
}
#[test]
@@ -2259,22 +2286,107 @@ mod tests {
}
#[test]
fn accepts_connection_and_table_context_menu_targets() {
fn accepts_connection_and_table_context_menu_targets() -> Result<(), Box<dyn std::error::Error>> {
for menu in ["connection", "table"] {
let (dir, manifest) = context_menu_manifest(menu);
let (dir, manifest) = context_menu_manifest(menu)?;
let compatibility = manifest.compatibility(dir.path(), "0.1.0");
assert!(compatibility.compatible, "{menu}: {:?}", compatibility.errors);
}
Ok(())
}
#[test]
fn rejects_unsupported_context_menu_targets() {
let (dir, manifest) = context_menu_manifest("schema");
fn parses_and_accepts_declarative_context_menu_workbench_without_backend() -> Result<(), Box<dyn std::error::Error>>
{
let plugin_dir = tempfile::tempdir()?;
let workbench: PluginContribution = serde_json::from_value(serde_json::json!({
"type": "workbench",
"id": "sample.main",
"label": "Sample"
}))?;
let context_menu: PluginContribution = serde_json::from_value(serde_json::json!({
"type": "context-menu",
"id": "sample.open",
"label": "Open Sample",
"menu": "connection",
"action": { "type": "open-workbench", "workbench": "sample.main" }
}))?;
let mut errors = Vec::new();
validate_contributions(&[workbench, context_menu], false, true, plugin_dir.path(), &mut errors);
assert!(errors.is_empty(), "{errors:?}");
Ok(())
}
#[test]
fn rejects_dangling_and_invalid_context_menu_actions() -> Result<(), Box<dyn std::error::Error>> {
let plugin_dir = tempfile::tempdir()?;
let dangling: PluginContribution = serde_json::from_value(serde_json::json!({
"type": "context-menu",
"id": "sample.open",
"label": "Open Missing",
"menu": "table",
"action": { "type": "open-workbench", "workbench": "sample.missing" }
}))?;
let mut errors = Vec::new();
validate_contributions(&[dangling], false, true, plugin_dir.path(), &mut errors);
assert!(errors.iter().any(|error| error.contains("Context menu 'sample.open' references missing workbench 'sample.missing'")), "{errors:?}");
assert!(!errors.iter().any(|error| error.contains("requires a backend entrypoint")), "{errors:?}");
let unknown_action = serde_json::from_value::<PluginContribution>(serde_json::json!({
"type": "context-menu",
"id": "sample.open",
"label": "Open",
"menu": "connection",
"action": { "type": "invoke-sidecar", "method": "contextMenu/sample.open" }
}));
assert!(unknown_action.is_err());
let command_only_fields = serde_json::from_value::<PluginContribution>(serde_json::json!({
"type": "context-menu",
"id": "sample.open",
"label": "Open",
"menu": "connection",
"action": { "type": "open-workbench", "workbench": "sample.main", "presentation": "panel" }
}));
assert!(command_only_fields.is_err());
Ok(())
}
#[test]
fn legacy_context_menu_still_requires_a_backend_entrypoint() -> Result<(), Box<dyn std::error::Error>> {
let plugin_dir = tempfile::tempdir()?;
let legacy: PluginContribution = serde_json::from_value(serde_json::json!({
"type": "context-menu",
"id": "sample.legacy",
"label": "Legacy",
"menu": "connection"
}))?;
let mut errors = Vec::new();
validate_contributions(std::slice::from_ref(&legacy), false, false, plugin_dir.path(), &mut errors);
assert!(
errors
.iter()
.any(|error| error == "Context menu contribution 'sample.legacy' requires a backend entrypoint"),
"{errors:?}"
);
let mut errors = Vec::new();
validate_contributions(&[legacy], true, false, plugin_dir.path(), &mut errors);
assert!(errors.is_empty(), "{errors:?}");
Ok(())
}
#[test]
fn rejects_unsupported_context_menu_targets() -> Result<(), Box<dyn std::error::Error>> {
let (dir, manifest) = context_menu_manifest("schema")?;
let compatibility = manifest.compatibility(dir.path(), "0.1.0");
assert!(!compatibility.compatible);
assert!(compatibility.errors.iter().any(|error| {
error == "Context menu 'io.dbx.example.inspect' declares unsupported menu 'schema'; only 'connection' and 'table' are available"
}));
Ok(())
}
#[test]
@@ -2293,6 +2405,12 @@ mod tests {
.collect::<Vec<_>>();
assert_eq!(declared, ["connection", "table"]);
assert_eq!(
schema["$defs"]["contextMenuContribution"]["properties"]["action"]["$ref"],
"#/$defs/contextMenuAction"
);
assert_eq!(schema["$defs"]["contextMenuAction"]["properties"]["type"]["const"], "open-workbench");
assert_eq!(schema["$defs"]["contextMenuAction"]["required"], serde_json::json!(["type", "workbench"]));
}
#[test]