// PR-A4 command registry + appSidebar placements (HOST_PLUGIN_UI_SPEC §4/§5). // The host keeps a static command registry per installed plugin and renders // placements from declared `menus` data only — it never scans workbenches to // invent entries. Executing a command re-resolves the workbench reference and // opens the plugin workbench with a host-authored context: the plugin payload // rides under `context.plugin`, while workbenchId/restored/surface are // injected here and can never be supplied by the plugin (§11 ownership). import { onScopeDispose, ref, shallowRef, watch } from "vue"; import { useI18n } from "vue-i18n"; import * as api from "@/lib/backend/api"; import { uuid } from "@/lib/common/utils"; import { activatePluginDockEntry, addPluginDockEntry, findReusableDockEntry } from "@/lib/plugins/pluginBottomDock"; import { createFrontendPluginRegistry, evaluatePluginCommandConditions, type FrontendPluginRegistry } from "@/lib/plugins/frontendPlugin"; import { useQueryStore } from "@/stores/queryStore"; import type { PluginCommandContribution, PluginConditionContextKeys } from "@/types/database"; export interface PluginToolbarCommandEntry { pluginId: string; pluginName: string; commandId: string; label: string; description?: string; icon?: string; } export interface PluginCommandExecutionResult { error?: string; } /** * §5.4 re-validate enablement right before execution (against the current context snapshot; the surface key follows * presentation). */ export function executePluginCommand(registry: FrontendPluginRegistry, queryStore: ReturnType, pluginId: string, commandId: string): PluginCommandExecutionResult { const command = registry.findCommand(pluginId, commandId)?.contribution; if (!command) return { error: `Unknown command '${pluginId}.${commandId}'` }; const action = command.action; if (action.type !== "open-workbench") return { error: `Command '${pluginId}.${commandId}' has an unsupported action` }; const workbench = registry.findWorkbench(pluginId, action.workbench); if (!workbench) return { error: `Command '${pluginId}.${commandId}' references missing workbench '${action.workbench}'` }; // §5.4 re-validate enablement right before execution (against the current context snapshot; the surface key follows // presentation). const commandContextKeys: PluginConditionContextKeys = { surface: action.presentation === "panel" ? "panel" : "tab", "connection.state": "none", readOnly: false }; if (!evaluatePluginCommandConditions(command.enablement?.all, commandContextKeys)) { return { error: `Command '${pluginId}.${commandId}' is disabled by enablement` }; } // presentation: panel -> adds an entry to the global bottom dock (§8.3); tab (default) // -> opens a workbench tab. if (action.presentation === "panel") { // §4.1: singleton (default) re-activates the existing panel instance // (reuse key = pluginId + commandId + presentation + instance_key); // reuse:"new" always spawns one. The dock "+" menu bypasses this path by // design — it is the explicit multi-open affordance. if (action.reuse !== "new") { const existing = findReusableDockEntry(pluginId, command.id, action.instance_key); if (existing) { activatePluginDockEntry(existing.id); return {}; } } addPluginDockEntry({ pluginId, workbenchContributionId: action.workbench, kind: "command", commandId: command.id, instanceKey: action.instance_key, title: command.label, icon: command.icon, commandContext: action.context ?? {}, }); return {}; } // Host-authored context: rebuilt from action.context — plugin-supplied reserved fields // (workbenchId/restored/surface) never pass through; the workbenchId is generated by the host. const context = { ...(action.context ?? {}), workbenchId: uuid(), restored: false, surface: "tab", }; // singleton (default) reuses the existing instance tab; new forces a fresh one. queryStore.openPluginWorkbench(pluginId, action.workbench, { commandId: command.id, title: command.label, context, forceNew: action.reuse === "new", }); return {}; } export function usePluginToolbarCommands() { const { locale } = useI18n(); const entries = ref([]); // shallowRef: the registry is a class instance with private fields — a deep ref's UnwrapRef would break // nominal type (TS expands it into a plain structural object, making it unassignable). const registry = shallowRef(null); async function refresh() { try { const installedPlugins = await api.listPlugins(); const nextRegistry = createFrontendPluginRegistry(installedPlugins, locale.value); entries.value = nextRegistry.listToolbarMenuCommands().map(({ plugin, command }: { plugin: { manifest: { id: string; name: string } }; command: PluginCommandContribution }) => ({ pluginId: plugin.manifest.id, pluginName: plugin.manifest.name, commandId: command.id, label: command.label, description: command.description, icon: command.icon, })); registry.value = nextRegistry; } catch (cause) { console.warn("[DBX][plugin:toolbar-entries]", cause); entries.value = []; registry.value = null; } } function open(entry: PluginToolbarCommandEntry): PluginCommandExecutionResult { if (!registry.value) return { error: "Plugin registry is not ready" }; // Lazy store access: pinia is only needed when an entry is actually clicked (mount-time has no store dependency; // plain host tests need no Pinia). return executePluginCommand(registry.value, useQueryStore(), entry.pluginId, entry.commandId); } watch(locale, () => void refresh()); // Refresh entries on plugin install/uninstall/replace (plugin center broadcasts dbx:plugins-changed) or window focus so they match the installed-plugin list. // refresh entries so they match the installed-plugin list. const onPluginsChanged = () => void refresh(); window.addEventListener("dbx:plugins-changed", onPluginsChanged); window.addEventListener("focus", onPluginsChanged); onScopeDispose(() => { window.removeEventListener("dbx:plugins-changed", onPluginsChanged); window.removeEventListener("focus", onPluginsChanged); }); void refresh(); return { entries, refresh, open }; }