139 lines
6.4 KiB
TypeScript
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 };
|
|
}
|