feat(plugins): open plugin workbench from context menu
This commit is contained in:
1 parent
05a72de255
commit
4c1eaa6b16
10 files changed
+494
-69
No files matched your search
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user