mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(plugins): support dynamic context menus and dialogs
This commit is contained in:
@@ -1943,13 +1943,32 @@ function onNodeToggled(node: TreeNode, expanded: boolean) {
|
||||
syncSidebarTreeNodeExpansion(store.treeNodes, node, expanded);
|
||||
}
|
||||
|
||||
let contextMenuRequest = 0;
|
||||
function openSidebarContextMenu(event: MouseEvent, node: TreeNode, openContextMenu: (event: MouseEvent, itemsOverride?: ContextMenuItem[]) => void) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const request = ++contextMenuRequest;
|
||||
const items = sidebarTreeRuntime.buildContextMenu(node);
|
||||
sidebarContextMenuTarget.value = createSidebarActionTarget(node);
|
||||
sidebarContextMenuItems.value = items;
|
||||
// Pass the current row's resolved menu atomically. Waiting for the items prop
|
||||
// to flush would let the singleton menu briefly reuse the previous row menu.
|
||||
openContextMenu(event, items);
|
||||
const resolved = sidebarTreeRuntime.resolveContextMenu(node, items);
|
||||
const show = (menuItems: ContextMenuItem[]) => {
|
||||
if (request !== contextMenuRequest) return;
|
||||
sidebarContextMenuTarget.value = createSidebarActionTarget(node);
|
||||
sidebarContextMenuItems.value = menuItems;
|
||||
// Pass the current row's resolved menu atomically, including async plugin items.
|
||||
openContextMenu(event, menuItems);
|
||||
};
|
||||
if (resolved instanceof Promise) {
|
||||
const cancelPending = () => {
|
||||
contextMenuRequest += 1;
|
||||
};
|
||||
document.addEventListener("pointerdown", cancelPending, { capture: true, once: true });
|
||||
void resolved
|
||||
.then(show)
|
||||
.catch(() => show(items))
|
||||
.finally(() => {
|
||||
document.removeEventListener("pointerdown", cancelPending, true);
|
||||
});
|
||||
} else show(resolved);
|
||||
}
|
||||
|
||||
function openSidebarDangerDialog(request: SidebarDangerDialogRequest) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, watch, onBeforeUnmount, onScopeDispose, inject, reactive, ref, shallowRef } from "vue";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import PluginWorkbenchHost from "@/components/plugins/PluginWorkbenchHost.vue";
|
||||
import type { PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
|
||||
import { createRoutedSidebarDialogController, routedCanSetCreateDatabaseCharset } from "./sidebarDialogControllerRouting";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import { useSidebarDataOpenRuntime } from "@/composables/useSidebarDataOpenRuntime";
|
||||
@@ -74,8 +77,9 @@ import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import { activatePluginContextMenuItem, buildPluginConnectionContextMenuInvocation, buildPluginTableContextMenuInvocation } from "@/lib/plugins/pluginContext";
|
||||
import { parseDynamicMenuResponse, renderDynamicMenuEntries, type DynamicMenuAction } from "@/lib/plugins/dynamicContextMenu";
|
||||
import type { PluginContextMenuInvocation } from "@/lib/plugins/pluginContext";
|
||||
import type { InstalledPlugin, PluginContextMenuContribution } from "@/types/database";
|
||||
import type { InstalledPlugin, PluginContextMenuContribution, PluginWorkbenchContribution } from "@/types/database";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
@@ -387,6 +391,7 @@ const savedSqlStore = useSavedSqlStore();
|
||||
const { toast } = useToast();
|
||||
const installedPlugins = ref<InstalledPlugin[]>([]);
|
||||
const sidebarPluginRegistry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
|
||||
const pluginDialog = shallowRef<{ plugin: InstalledPlugin; contribution: PluginWorkbenchContribution; context: PluginWorkbenchContext; title: string } | null>(null);
|
||||
|
||||
async function refreshInstalledPlugins() {
|
||||
try {
|
||||
@@ -6865,17 +6870,19 @@ function appendPluginConnectionMenuItems(items: ContextMenuItem[], node: TreeNod
|
||||
if (pluginItems.length === 0) return;
|
||||
const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined;
|
||||
if (!config) return;
|
||||
const menuItems = pluginItems.flatMap(({ plugin, contribution }) => {
|
||||
const invocation = buildPluginConnectionContextMenuInvocation(contribution.id, config);
|
||||
if (!invocation) return [];
|
||||
return [
|
||||
{
|
||||
label: contribution.label,
|
||||
icon: PlugZap,
|
||||
action: () => activateSidebarPluginContextMenuItem(plugin.manifest.id, contribution, invocation),
|
||||
},
|
||||
];
|
||||
});
|
||||
const menuItems = pluginItems
|
||||
.filter(({ contribution }) => !contribution.dynamic)
|
||||
.flatMap(({ plugin, contribution }) => {
|
||||
const invocation = buildPluginConnectionContextMenuInvocation(contribution.id, config);
|
||||
if (!invocation) return [];
|
||||
return [
|
||||
{
|
||||
label: contribution.label,
|
||||
icon: PlugZap,
|
||||
action: () => activateSidebarPluginContextMenuItem(plugin.manifest.id, contribution, invocation),
|
||||
},
|
||||
];
|
||||
});
|
||||
if (menuItems.length === 0) return;
|
||||
items.push({ label: "", separator: true }, ...menuItems);
|
||||
}
|
||||
@@ -6888,6 +6895,7 @@ function appendPluginTableMenuItems(items: ContextMenuItem[], node: TreeNode) {
|
||||
|
||||
const tableItems: ContextMenuItem[] = [];
|
||||
for (const { plugin, contribution } of pluginItems) {
|
||||
if (contribution.dynamic) continue;
|
||||
const invocation = buildPluginTableContextMenuInvocation(contribution.id, node);
|
||||
if (!invocation) continue;
|
||||
tableItems.push({
|
||||
@@ -6900,6 +6908,91 @@ function appendPluginTableMenuItems(items: ContextMenuItem[], node: TreeNode) {
|
||||
items.push({ label: "", separator: true }, ...tableItems);
|
||||
}
|
||||
|
||||
/** Resolve opt-in contributions only for the row being opened. A failing plugin cannot hold the menu open. */
|
||||
function resolveContextMenu(node: TreeNode, staticItems: ContextMenuItem[]): Promise<ContextMenuItem[]> | ContextMenuItem[] {
|
||||
if (node.type !== "connection" && node.type !== "table") return staticItems;
|
||||
const entries = sidebarPluginRegistry.value.listContextMenuItems(node.type).filter(({ contribution }) => contribution.dynamic);
|
||||
if (entries.length === 0) return staticItems;
|
||||
const requests = entries.map(async ({ plugin, contribution }) => {
|
||||
const invocation = node.type === "connection" ? (node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined) : undefined;
|
||||
const target = node.type === "connection" ? (invocation ? buildPluginConnectionContextMenuInvocation(contribution.id, invocation) : null) : buildPluginTableContextMenuInvocation(contribution.id, node);
|
||||
if (!target) return [];
|
||||
try {
|
||||
const ownerPluginId = node.type === "connection" && node.connectionId ? connectionStore.getConfig(node.connectionId)?.plugin_id : undefined;
|
||||
const response = await api.invokePlugin(
|
||||
plugin.manifest.id,
|
||||
`contextMenu/resolve/${contribution.id}`,
|
||||
{
|
||||
...target.params,
|
||||
locale: appLocale.value,
|
||||
...(ownerPluginId ? { ownerPluginId } : {}),
|
||||
},
|
||||
500,
|
||||
);
|
||||
const resolved = parseDynamicMenuResponse(response);
|
||||
if (!resolved) return [];
|
||||
const activate = (action: DynamicMenuAction, label: string) => {
|
||||
if (action.type === "open-workbench") {
|
||||
if (action.presentation === "dialog") {
|
||||
const workbench = sidebarPluginRegistry.value.findWorkbench(plugin.manifest.id, action.workbench);
|
||||
if (!workbench) {
|
||||
toast(`Plugin workbench '${plugin.manifest.id}/${action.workbench}' is unavailable`, 5000);
|
||||
return;
|
||||
}
|
||||
pluginDialog.value = { ...workbench, context: target.context, title: label };
|
||||
return;
|
||||
}
|
||||
activateSidebarPluginContextMenuItem(plugin.manifest.id, { ...contribution, label, action }, target);
|
||||
return;
|
||||
}
|
||||
const invoke = () => api.invokePlugin(plugin.manifest.id, target.method, { ...target.params, itemId: action.id });
|
||||
void invoke()
|
||||
.catch(async (error: unknown) => {
|
||||
if (!action.reopenConnectionOnMissing || node.type !== "connection" || !target.connectionId || !String((error as Error)?.message || error).includes("Connection is not active")) throw error;
|
||||
await connectionStore.reopenPluginConnection(target.connectionId, plugin.manifest.id);
|
||||
return invoke();
|
||||
})
|
||||
.then((result) => {
|
||||
const message = (result as { message?: unknown } | null)?.message;
|
||||
if (typeof message === "string" && message.trim()) toast(message, 4000);
|
||||
})
|
||||
.catch((error: unknown) => toast(String((error as Error)?.message || error), 5000));
|
||||
};
|
||||
return renderDynamicMenuEntries(resolved, activate).map((item) => ({ ...item, icon: PlugZap }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
return Promise.all(requests).then((groups) => {
|
||||
const dynamicItems = groups.flat();
|
||||
return dynamicItems.length ? [...staticItems, { label: "", separator: true }, ...dynamicItems] : staticItems;
|
||||
});
|
||||
}
|
||||
|
||||
// Dialog-hosted workbenches navigate away from the modal surface the same way
|
||||
// the tab path does (PluginWorkbenchTab): open-workbench swaps the dialog for a
|
||||
// workbench tab via queryStore.openPluginWorkbench; open-filesystem opens the
|
||||
// declared provider tab. The bridge already scopes both to the owner plugin.
|
||||
function openWorkbench(pluginId: string, contributionId: string, context?: PluginWorkbenchContext, options?: { forceNew?: boolean }) {
|
||||
const target = sidebarPluginRegistry.value.findWorkbench(pluginId, contributionId);
|
||||
const contextConnectionId = typeof context?.connectionId === "string" ? context.connectionId : "";
|
||||
const connectionName = contextConnectionId ? connectionStore.getConfig(contextConnectionId)?.name : undefined;
|
||||
pluginDialog.value = null;
|
||||
queryStore.openPluginWorkbench(pluginId, contributionId, { title: connectionName || target?.contribution.label || contributionId, context, forceNew: options?.forceNew === true });
|
||||
}
|
||||
|
||||
function openFilesystem(pluginId: string, providerId: string, context?: PluginWorkbenchContext) {
|
||||
const target = sidebarPluginRegistry.value.listFilesystemProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId);
|
||||
if (!target) throw new Error(t("pluginPlatform.filesystemUnavailable", { pluginId, providerId }));
|
||||
pluginDialog.value = null;
|
||||
queryStore.openPluginFilesystem(pluginId, providerId, {
|
||||
title: target.contribution.label,
|
||||
connectionId: typeof context?.connectionId === "string" ? context.connectionId : undefined,
|
||||
rootUri: target.contribution.root_uri,
|
||||
currentUri: typeof context?.uri === "string" ? context.uri : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function activateRuntimeNode(node: TreeNode) {
|
||||
activeNode.value = node;
|
||||
}
|
||||
@@ -7025,6 +7118,7 @@ function toggleNode(node: TreeNode) {
|
||||
|
||||
defineExpose({
|
||||
buildContextMenu,
|
||||
resolveContextMenu,
|
||||
handleRowClick,
|
||||
handleRowDoubleClick,
|
||||
handleRowKeydown,
|
||||
@@ -7036,4 +7130,27 @@ defineExpose({
|
||||
});
|
||||
</script>
|
||||
|
||||
<template />
|
||||
<template>
|
||||
<Dialog
|
||||
:open="!!pluginDialog"
|
||||
@update:open="
|
||||
(open) => {
|
||||
if (!open) pluginDialog = null;
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogContent class="h-[min(82vh,780px)] max-w-[min(1080px,calc(100vw-2rem))] gap-0 p-0">
|
||||
<DialogTitle class="sr-only">{{ pluginDialog?.title }}</DialogTitle>
|
||||
<PluginWorkbenchHost
|
||||
v-if="pluginDialog"
|
||||
class="min-h-0 size-full overflow-hidden rounded-lg"
|
||||
:plugin="pluginDialog.plugin"
|
||||
:contribution="pluginDialog.contribution"
|
||||
:context="pluginDialog.context"
|
||||
@open-workbench="openWorkbench"
|
||||
@open-filesystem="openFilesystem"
|
||||
@close-tab="pluginDialog = null"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
+126
-3
@@ -6,15 +6,17 @@ import i18n from "@/i18n";
|
||||
import type { ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import type { InstalledPlugin, TreeNode } from "@/types/database";
|
||||
|
||||
const { listPlugins } = vi.hoisted(() => ({ listPlugins: vi.fn() }));
|
||||
const { listPlugins, invokePlugin } = vi.hoisted(() => ({ listPlugins: vi.fn(), invokePlugin: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/backend/api")>();
|
||||
return { ...actual, listPlugins };
|
||||
return { ...actual, listPlugins, invokePlugin };
|
||||
});
|
||||
vi.mock("@/components/plugins/PluginWorkbenchHost.vue", () => ({ default: { props: ["plugin", "contribution", "context"], template: '<div data-test="plugin-dialog-workbench">{{ contribution.id }}:{{ context.id }}</div>' } }));
|
||||
|
||||
import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
@@ -73,7 +75,7 @@ async function mountHost() {
|
||||
mountedApps.push(app);
|
||||
await flush();
|
||||
expect(host.value).not.toBeNull();
|
||||
return { host: host as unknown as { value: { buildContextMenu(node: TreeNode): ContextMenuItem[] } }, container };
|
||||
return { host: host as unknown as { value: { buildContextMenu(node: TreeNode): ContextMenuItem[]; resolveContextMenu(node: TreeNode, items: ContextMenuItem[]): Promise<ContextMenuItem[]> | ContextMenuItem[] } }, container };
|
||||
}
|
||||
|
||||
function menuLabels(items: ContextMenuItem[]): string[] {
|
||||
@@ -84,6 +86,127 @@ describe("SidebarTreeRuntimeHost plugin context-menu refresh", () => {
|
||||
beforeEach(() => {
|
||||
listPlugins.mockReset();
|
||||
listPlugins.mockResolvedValue([]);
|
||||
invokePlugin.mockReset();
|
||||
});
|
||||
|
||||
it("resolves connection-specific second-level items on each open and dispatches the selected id", async () => {
|
||||
listPlugins.mockResolvedValue([
|
||||
{
|
||||
compatibility: { compatible: true },
|
||||
manifest: {
|
||||
id: "com.example.tunnels",
|
||||
name: "Tunnels",
|
||||
version: "1.0.0",
|
||||
drivers: [],
|
||||
contributions: [{ type: "context-menu", id: "tunnels", label: "Tunnels", menu: "connection", dynamic: true }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
invokePlugin.mockResolvedValue({});
|
||||
invokePlugin.mockResolvedValueOnce({ items: [{ label: "Tunnels", children: [{ label: "Start saved", action: { type: "invoke", id: "start-all" } }] }] });
|
||||
const { host } = await mountHost();
|
||||
const first = await host.value.resolveContextMenu(connectionNode(), host.value.buildContextMenu(connectionNode()));
|
||||
expect(menuLabels(first)).toContain("Start saved");
|
||||
expect(invokePlugin).toHaveBeenCalledWith("com.example.tunnels", "contextMenu/resolve/tunnels", { connection: { id: "conn-1", dbType: "mysql", name: "Test MySQL", database: "" }, locale: expect.any(String) }, 500);
|
||||
first.find((item) => item.label === "Tunnels")?.children?.[0].action?.();
|
||||
await flush();
|
||||
expect(invokePlugin).toHaveBeenCalledWith("com.example.tunnels", "contextMenu/tunnels", expect.objectContaining({ itemId: "start-all" }));
|
||||
|
||||
invokePlugin.mockResolvedValueOnce({ items: [{ label: "Tunnels", children: [{ label: "Stop all", action: { type: "invoke", id: "stop-all" } }] }] });
|
||||
const second = await host.value.resolveContextMenu(connectionNode(), host.value.buildContextMenu(connectionNode()));
|
||||
expect(menuLabels(second)).toContain("Stop all");
|
||||
expect(menuLabels(second)).not.toContain("Start saved");
|
||||
});
|
||||
|
||||
it("opens a declared plugin workbench in a dialog without creating a tab", async () => {
|
||||
listPlugins.mockResolvedValue([
|
||||
{
|
||||
compatibility: { compatible: true },
|
||||
manifest: {
|
||||
id: "com.example.forwards",
|
||||
name: "Port forwards",
|
||||
version: "1.0.0",
|
||||
drivers: [],
|
||||
contributions: [
|
||||
{ type: "context-menu", id: "forwards", label: "Port forwards", menu: "connection", dynamic: true },
|
||||
{ type: "workbench", id: "com.example.forwards.manager", label: "Port forwards" },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
invokePlugin.mockResolvedValue({ items: [{ label: "Port forwards", children: [{ label: "Manage port forwards", action: { type: "open-workbench", workbench: "com.example.forwards.manager", presentation: "dialog" } }] }] });
|
||||
const { host } = await mountHost();
|
||||
const openTab = vi.spyOn(useQueryStore(), "openPluginWorkbench");
|
||||
const items = await host.value.resolveContextMenu(connectionNode(), host.value.buildContextMenu(connectionNode()));
|
||||
items.find((item) => item.label === "Port forwards")?.children?.[0].action?.();
|
||||
await flush();
|
||||
expect(document.querySelector('[data-test="plugin-dialog-workbench"]')?.textContent).toContain("com.example.forwards.manager:conn-1");
|
||||
expect(openTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes canonical table context to dynamic menu resolvers", async () => {
|
||||
listPlugins.mockResolvedValue([
|
||||
{
|
||||
compatibility: { compatible: true },
|
||||
manifest: {
|
||||
id: "com.example.table",
|
||||
name: "Table",
|
||||
version: "1.0.0",
|
||||
drivers: [],
|
||||
contributions: [{ type: "context-menu", id: "table.actions", label: "Actions", menu: "table", dynamic: true }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
invokePlugin.mockResolvedValue({ items: [{ label: "Inspect" }] });
|
||||
const { host } = await mountHost();
|
||||
const items = await host.value.resolveContextMenu(tableNode(), host.value.buildContextMenu(tableNode()));
|
||||
expect(menuLabels(items)).toContain("Inspect");
|
||||
expect(invokePlugin).toHaveBeenCalledWith("com.example.table", "contextMenu/resolve/table.actions", { table: { connectionId: "conn-1", database: "app", table: "users" }, locale: expect.any(String) }, 500);
|
||||
});
|
||||
|
||||
it("restores an inactive plugin connection before retrying an opted-in menu action", async () => {
|
||||
listPlugins.mockResolvedValue([
|
||||
{
|
||||
compatibility: { compatible: true },
|
||||
manifest: {
|
||||
id: "com.example.tunnels",
|
||||
name: "Tunnels",
|
||||
version: "1.0.0",
|
||||
drivers: [],
|
||||
contributions: [{ type: "context-menu", id: "tunnels", label: "Tunnels", menu: "connection", dynamic: true }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
invokePlugin.mockResolvedValueOnce({ items: [{ label: "Tunnels", children: [{ label: "Start saved", action: { type: "invoke", id: "start-all", reopenConnectionOnMissing: true } }] }] });
|
||||
invokePlugin.mockRejectedValueOnce(new Error("Connection is not active; reopen it from DBX"));
|
||||
invokePlugin.mockResolvedValueOnce({ message: "Started 1 tunnel" });
|
||||
const { host } = await mountHost();
|
||||
const reconnect = vi.spyOn(useConnectionStore(), "reopenPluginConnection").mockResolvedValue(undefined);
|
||||
const items = await host.value.resolveContextMenu(connectionNode(), host.value.buildContextMenu(connectionNode()));
|
||||
items.find((item) => item.label === "Tunnels")?.children?.[0].action?.();
|
||||
await flush();
|
||||
expect(reconnect).toHaveBeenCalledWith("conn-1", "com.example.tunnels");
|
||||
expect(invokePlugin.mock.calls.filter((call) => call[1] === "contextMenu/tunnels")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("passes plugin ownership to a dynamic connection resolver", async () => {
|
||||
listPlugins.mockResolvedValue([
|
||||
{
|
||||
compatibility: { compatible: true },
|
||||
manifest: {
|
||||
id: "com.example.tunnels",
|
||||
name: "Tunnels",
|
||||
version: "1.0.0",
|
||||
drivers: [],
|
||||
contributions: [{ type: "context-menu", id: "tunnels", label: "Tunnels", menu: "connection", dynamic: true }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
invokePlugin.mockResolvedValue({ items: [] });
|
||||
const { host } = await mountHost();
|
||||
useConnectionStore().connections = [{ ...connection, db_type: "plugin", plugin_id: "com.example.tunnels" }];
|
||||
await host.value.resolveContextMenu(connectionNode(), host.value.buildContextMenu(connectionNode()));
|
||||
expect(invokePlugin).toHaveBeenCalledWith("com.example.tunnels", "contextMenu/resolve/tunnels", expect.objectContaining({ ownerPluginId: "com.example.tunnels" }), 500);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parseDynamicMenuResponse, renderDynamicMenuEntries } from "./dynamicContextMenu";
|
||||
|
||||
describe("dynamic plugin context menus", () => {
|
||||
it("renders a state-dependent native submenu and dispatches only validated actions", () => {
|
||||
const entries = parseDynamicMenuResponse({
|
||||
items: [
|
||||
{
|
||||
label: "Tunnels",
|
||||
children: [
|
||||
{ label: "Manage", action: { type: "open-workbench", workbench: "ssh.tunnels", presentation: "dialog" } },
|
||||
{ label: "Start saved", action: { type: "invoke", id: "start-all" } },
|
||||
{ label: "Hidden", visible: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(entries).not.toBeNull();
|
||||
const activate = vi.fn();
|
||||
const items = renderDynamicMenuEntries(entries!, activate);
|
||||
expect(items[0].children?.map((child) => child.label)).toEqual(["Manage", "Start saved"]);
|
||||
items[0].children?.[1].action?.();
|
||||
expect(activate).toHaveBeenCalledWith({ type: "invoke", id: "start-all" }, "Start saved");
|
||||
items[0].children?.[0].action?.();
|
||||
expect(activate).toHaveBeenCalledWith({ type: "open-workbench", workbench: "ssh.tunnels", presentation: "dialog" }, "Manage");
|
||||
});
|
||||
|
||||
it("rejects nested menus, arbitrary methods, invalid types and oversized responses", () => {
|
||||
expect(parseDynamicMenuResponse({ items: [{ label: "Parent", children: [{ label: "Nested", children: [] }] }] })).toBeNull();
|
||||
expect(parseDynamicMenuResponse({ items: [{ label: "Run", action: { type: "invoke", method: "arbitrary" } }] })).toBeNull();
|
||||
expect(parseDynamicMenuResponse({ items: [{ label: "Run", action: { type: "invoke", id: "start", reopenConnectionOnMissing: "yes" } }] })).toBeNull();
|
||||
expect(parseDynamicMenuResponse({ items: [{ label: "Manage", action: { type: "open-workbench", workbench: "ssh.tunnels", presentation: "window" } }] })).toBeNull();
|
||||
expect(parseDynamicMenuResponse({ items: [{ label: "Run", enabled: "yes" }] })).toBeNull();
|
||||
expect(parseDynamicMenuResponse({ items: Array.from({ length: 25 }, () => ({ label: "X" })) })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides empty contributions and disables parents with no visible children", () => {
|
||||
expect(parseDynamicMenuResponse({ items: [] })).toEqual([]);
|
||||
const entries = parseDynamicMenuResponse({ items: [{ label: "Tunnels", children: [{ label: "Hidden", visible: false }] }] });
|
||||
expect(renderDynamicMenuEntries(entries!, vi.fn())).toEqual([expect.objectContaining({ label: "Tunnels", disabled: true, children: [] })]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ContextMenuItem } from "@/components/ui/customContextMenuRegistry";
|
||||
|
||||
export type DynamicMenuAction = { type: "invoke"; id: string; reopenConnectionOnMissing?: boolean } | { type: "open-workbench"; workbench: string; presentation?: "dialog" };
|
||||
|
||||
export interface DynamicMenuEntry {
|
||||
label: string;
|
||||
visible?: boolean;
|
||||
enabled?: boolean;
|
||||
checked?: boolean;
|
||||
action?: DynamicMenuAction;
|
||||
children?: DynamicMenuEntry[];
|
||||
}
|
||||
|
||||
const IDENTIFIER = /^[a-zA-Z0-9._:/-]{1,256}$/;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function parseAction(value: unknown): DynamicMenuAction | undefined {
|
||||
const object = record(value);
|
||||
if (!object) return undefined;
|
||||
if (object.type === "invoke" && typeof object.id === "string" && IDENTIFIER.test(object.id)) {
|
||||
if (object.reopenConnectionOnMissing !== undefined && typeof object.reopenConnectionOnMissing !== "boolean") return undefined;
|
||||
return { type: "invoke", id: object.id, ...(object.reopenConnectionOnMissing === true ? { reopenConnectionOnMissing: true } : {}) };
|
||||
}
|
||||
if (object.type === "open-workbench" && typeof object.workbench === "string" && IDENTIFIER.test(object.workbench)) {
|
||||
if (object.presentation !== undefined && object.presentation !== "dialog") return undefined;
|
||||
return { type: "open-workbench", workbench: object.workbench, ...(object.presentation === "dialog" ? { presentation: "dialog" as const } : {}) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseEntry(value: unknown, depth: number): DynamicMenuEntry | null {
|
||||
const object = record(value);
|
||||
if (!object || typeof object.label !== "string" || !object.label.trim() || object.label.length > 120) return null;
|
||||
if (object.visible !== undefined && typeof object.visible !== "boolean") return null;
|
||||
if (object.enabled !== undefined && typeof object.enabled !== "boolean") return null;
|
||||
if (object.checked !== undefined && typeof object.checked !== "boolean") return null;
|
||||
if (object.action !== undefined && !parseAction(object.action)) return null;
|
||||
if (object.children !== undefined && (depth !== 0 || !Array.isArray(object.children) || object.children.length > 40 || object.action !== undefined)) return null;
|
||||
const children = Array.isArray(object.children) ? object.children.map((child) => parseEntry(child, 1)) : undefined;
|
||||
if (children?.some((child) => child === null)) return null;
|
||||
return {
|
||||
label: object.label.trim(),
|
||||
visible: object.visible as boolean | undefined,
|
||||
enabled: object.enabled as boolean | undefined,
|
||||
checked: object.checked as boolean | undefined,
|
||||
action: parseAction(object.action),
|
||||
children: children as DynamicMenuEntry[] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reject malformed or oversized sidecar responses before they reach the native menu. */
|
||||
export function parseDynamicMenuResponse(value: unknown): DynamicMenuEntry[] | null {
|
||||
const object = record(value);
|
||||
if (!object || !Array.isArray(object.items) || object.items.length > 24) return null;
|
||||
const items = object.items.map((item) => parseEntry(item, 0));
|
||||
return items.some((item) => item === null) ? null : (items as DynamicMenuEntry[]);
|
||||
}
|
||||
|
||||
export function renderDynamicMenuEntries(entries: DynamicMenuEntry[], activate: (action: DynamicMenuAction, label: string) => void): ContextMenuItem[] {
|
||||
return entries
|
||||
.filter((entry) => entry.visible !== false)
|
||||
.map((entry) => {
|
||||
const children = entry.children ? renderDynamicMenuEntries(entry.children, activate) : undefined;
|
||||
return {
|
||||
label: entry.label,
|
||||
disabled: entry.enabled === false || (children !== undefined && children.length === 0),
|
||||
checked: entry.checked,
|
||||
action: entry.action ? () => activate(entry.action!, entry.label) : undefined,
|
||||
children,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface SidebarTreeRuntimeGeneration {
|
||||
|
||||
export interface SidebarTreeRuntimeHost {
|
||||
buildContextMenu(node: TreeNode): ContextMenuItem[];
|
||||
resolveContextMenu(node: TreeNode, staticItems: ContextMenuItem[]): Promise<ContextMenuItem[]> | ContextMenuItem[];
|
||||
handleRowClick(node: TreeNode, clickDetail: number): void;
|
||||
handleRowDoubleClick(node: TreeNode, event: MouseEvent): void;
|
||||
handleRowKeydown(node: TreeNode, event: KeyboardEvent): void;
|
||||
@@ -37,6 +38,7 @@ export interface SidebarTreeRuntime {
|
||||
beginAction(): SidebarTreeRuntimeGeneration;
|
||||
isCurrent(token: SidebarTreeRuntimeGeneration): boolean;
|
||||
buildContextMenu(node: TreeNode): ContextMenuItem[];
|
||||
resolveContextMenu(node: TreeNode, staticItems: ContextMenuItem[]): Promise<ContextMenuItem[]> | ContextMenuItem[];
|
||||
handleRowClick(node: TreeNode, clickDetail: number): void;
|
||||
handleRowDoubleClick(node: TreeNode, event: MouseEvent): void;
|
||||
handleRowKeydown(node: TreeNode, event: KeyboardEvent): void;
|
||||
@@ -91,6 +93,9 @@ export function createSidebarTreeRuntime(): SidebarTreeRuntime {
|
||||
menuBuilds += 1;
|
||||
return currentHost()?.buildContextMenu(node) ?? [];
|
||||
},
|
||||
resolveContextMenu(node, staticItems) {
|
||||
return currentHost()?.resolveContextMenu(node, staticItems) ?? staticItems;
|
||||
},
|
||||
handleRowClick(node, clickDetail) {
|
||||
currentHost()?.handleRowClick(node, clickDetail);
|
||||
},
|
||||
|
||||
@@ -506,6 +506,7 @@ export interface PluginContextMenuContribution {
|
||||
description?: string;
|
||||
icon?: string;
|
||||
menu: PluginContextMenuTarget;
|
||||
dynamic?: boolean;
|
||||
action?: PluginOpenWorkbenchTarget;
|
||||
}
|
||||
|
||||
|
||||
@@ -737,6 +737,9 @@ pub struct PluginContextMenuContribution {
|
||||
/// Menu surface the item belongs to: `connection` or `table`.
|
||||
#[serde(default)]
|
||||
pub menu: String,
|
||||
/// Resolve native items through `contextMenu/resolve/<id>` on each menu open.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub dynamic: bool,
|
||||
/// Optional host-handled action. When absent, the legacy backend entrypoint is required.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub action: Option<PluginContextMenuAction>,
|
||||
@@ -1433,6 +1436,9 @@ fn validate_contributions(
|
||||
} else if !has_backend {
|
||||
errors.push(format!("Context menu contribution '{id}' requires a backend entrypoint"));
|
||||
}
|
||||
if menu.dynamic && !has_backend {
|
||||
errors.push(format!("Dynamic context menu contribution '{id}' requires a backend entrypoint"));
|
||||
}
|
||||
}
|
||||
PluginContribution::FilesystemProvider(provider) => {
|
||||
validate_required_text(&provider.label, &format!("Filesystem provider '{id}' label"), errors);
|
||||
@@ -2652,6 +2658,36 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_context_menu_requires_backend() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let plugin_dir = tempfile::tempdir()?;
|
||||
let context_menu: PluginContribution = serde_json::from_value(serde_json::json!({
|
||||
"type": "context-menu",
|
||||
"id": "sample.tunnels",
|
||||
"label": "Tunnels",
|
||||
"menu": "connection",
|
||||
"dynamic": true,
|
||||
"action": { "type": "open-workbench", "workbench": "sample.main" }
|
||||
}))?;
|
||||
let workbench: PluginContribution = serde_json::from_value(serde_json::json!({
|
||||
"type": "workbench", "id": "sample.main", "label": "Sample"
|
||||
}))?;
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&[workbench, context_menu], false, true, plugin_dir.path(), &mut errors);
|
||||
assert!(
|
||||
errors.iter().any(|error| error
|
||||
.contains("Dynamic context menu contribution 'sample.tunnels' requires a backend entrypoint")),
|
||||
"{errors:?}"
|
||||
);
|
||||
let context_menu: PluginContribution = serde_json::from_value(serde_json::json!({
|
||||
"type": "context-menu", "id": "sample.tunnels", "label": "Tunnels", "menu": "connection", "dynamic": true
|
||||
}))?;
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&[context_menu], true, false, 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()?;
|
||||
|
||||
@@ -478,6 +478,24 @@ A context-menu item can instead declare a host-handled Workbench action:
|
||||
|
||||
The `workbench` reference must identify a `workbench` contribution in the same plugin manifest. This declarative action is resolved by the host and does not invoke the plugin backend.
|
||||
|
||||
To provide state-dependent items and one level of native submenus, set `"dynamic": true` on a `context-menu` contribution. This requires a backend entrypoint. On each right-click, DBX calls `contextMenu/resolve/<id>` with the same non-secret `{ connection }` or `{ table }` envelope used by legacy actions, plus the current `locale`; connection menus also include `ownerPluginId` when the connection belongs to a plugin. A plugin can return an empty menu for connections it does not own. Return `{ "items": [...] }`; returning an empty array hides the contribution. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"label": "Tunnels",
|
||||
"children": [
|
||||
{ "label": "Manage port forwards", "action": { "type": "open-workbench", "workbench": "ssh.tunnels", "presentation": "dialog" } },
|
||||
{ "label": "Start saved tunnels", "action": { "type": "invoke", "id": "start-all", "reopenConnectionOnMissing": true } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each item accepts `label`, optional `visible`, `enabled`, `checked`, and either `action` or `children`. Children may not contain another submenu. An `invoke` action calls the declared contribution's existing `contextMenu/<id>` backend method with the original envelope plus `itemId`; the host never executes an arbitrary method supplied by the resolver. For connection items, `reopenConnectionOnMissing: true` retries once after DBX restores that plugin connection when the backend reports `Connection is not active`; omit it for actions that do not need an open connection. An `open-workbench` action must target a workbench declared by the same plugin. Dynamic menu items may add `"presentation": "dialog"` to that action to show the workbench in a modal over DBX; without it the workbench opens in a tab. The host limits response size and resolution time; invalid or failed responses contribute no items. Existing static context-menu contributions continue to work unchanged.
|
||||
|
||||
For legacy entries without `action`, clicking a connection item dispatches `contextMenu/<id>` with the existing non-secret connection summary (`{ id, dbType, name, database }`) under `connection`. Clicking a table item uses the same backend method and dispatches:
|
||||
|
||||
```json
|
||||
|
||||
@@ -349,6 +349,7 @@
|
||||
"description": { "type": "string" },
|
||||
"icon": { "$ref": "#/$defs/assetPath" },
|
||||
"menu": { "enum": ["connection", "table"] },
|
||||
"dynamic": { "type": "boolean", "description": "Resolve native menu items through contextMenu/resolve/<id> when the menu opens." },
|
||||
"action": { "$ref": "#/$defs/contextMenuAction" }
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user