Files
dbx/apps/desktop/src/lib/plugins/pluginCommandRegistry.ts
T

139 lines
6.4 KiB
TypeScript

// 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<typeof useQueryStore>, 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<PluginToolbarCommandEntry[]>([]);
// 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<FrontendPluginRegistry | null>(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 };
}