feat(plugins): add command and menu contributions with bottom dock
This commit is contained in:
1 parent
67dd9a6229
commit
aefecd3841
37 files changed
+2911
-34
No files matched your search
@@ -0,0 +1,137 @@
|
||||
// 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, {
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user