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
+5
-2
@@ -8,8 +8,11 @@ const mocks = vi.hoisted(() => ({
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
// PR-A4: AppSidebar now pulls useQueryStore/useI18n chains through
|
||||
// chains, so partially mock vue-i18n here while keeping the other exports (e.g. createI18n).
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({ t: (key: string) => key, locale: { value: "en" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
|
||||
@@ -11,6 +11,9 @@ import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue
|
||||
import ToolbarUpdateIcon from "@/components/layout/ToolbarUpdateIcon.vue";
|
||||
import { MAC_TRAFFIC_LIGHT_X, macTrafficLightInsetPaddingForScale, shouldReserveMacTrafficLightInset, useWindowControls } from "@/composables/useWindowControls";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import { setDockVisible, usePluginBottomDock } from "@/lib/plugins/pluginBottomDock";
|
||||
import { usePluginToolbarCommands, type PluginToolbarCommandEntry } from "@/lib/plugins/pluginCommandRegistry";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { isSystemAppThemeMode, type AppThemeMode } from "@/lib/app/appTheme";
|
||||
|
||||
@@ -76,6 +79,28 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
// PR-A4 appToolbar surface (HOST_PLUGIN_UI_SPEC §5.1): plugin commands render as icons
|
||||
// surface (next to Settings/AI); clicking runs the command (presentation: panel -> the global bottom dock),
|
||||
// clicking again collapses an open dock command.
|
||||
const { entries: pluginCommandEntries, open: openPluginCommand } = usePluginToolbarCommands();
|
||||
const { entries: pluginDockEntries, visible: dockVisible } = usePluginBottomDock();
|
||||
// The toolbar icon toggles panel visibility (panel hide keeps the webviews
|
||||
// mounted, so sessions and height survive); with a hidden-but-populated dock
|
||||
// the first click just restores it, and only an empty dock runs the command
|
||||
// (otherwise "getting the panel back" would keep spawning new terminals).
|
||||
function togglePluginCommand(entry: PluginToolbarCommandEntry) {
|
||||
if (dockVisible.value) {
|
||||
setDockVisible(false);
|
||||
return;
|
||||
}
|
||||
if (pluginDockEntries.value.length) {
|
||||
setDockVisible(true);
|
||||
return;
|
||||
}
|
||||
const result = openPluginCommand(entry);
|
||||
if (result.error) toast(result.error, 5000);
|
||||
}
|
||||
const settingsStore = useSettingsStore();
|
||||
const toolbarItems = computed(() => settingsStore.editorSettings.toolbarItems);
|
||||
const showToolbarUpdateEntry = computed(() => toolbarItems.value.checkUpdates || props.hasUpdateAvailable);
|
||||
@@ -771,6 +796,16 @@ const toolbarStyle = computed(() => {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>GitHub</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-for="entry in pluginCommandEntries" :key="`${entry.pluginId}.${entry.commandId}`">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" :class="{ 'toolbar-action-button--active bg-accent': dockVisible }" :aria-label="entry.label" @click="togglePluginCommand(entry)">
|
||||
<PluginIcon :plugin-id="entry.pluginId" :icon="entry.icon" class="toolbar-action-icon h-4 w-4" />
|
||||
<span v-if="dockVisible" class="toolbar-panel-status" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ entry.label }} · {{ entry.pluginName }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<!-- /rightWrapper -->
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, ref, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ listPlugins: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({ listPlugins: mocks.listPlugins }));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ locale: ref("en"), t: (key: string) => key }) }));
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [] }) }));
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: defineComponent(
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("button", slots.default?.()),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: defineComponent(
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
),
|
||||
TooltipTrigger: defineComponent(
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
),
|
||||
TooltipContent: defineComponent(
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
),
|
||||
}));
|
||||
vi.mock("./PluginIcon.vue", () => ({ default: defineComponent(() => () => h("span")) }));
|
||||
// Mirrors the real PluginWorkbenchHost, whose title computed dereferences
|
||||
// props.contribution.label unconditionally — an undefined contribution must
|
||||
// never reach it, otherwise the whole dock subtree crashes during render.
|
||||
vi.mock("./PluginWorkbenchHost.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: {
|
||||
plugin: { type: Object, required: true },
|
||||
contribution: { type: Object, required: true },
|
||||
context: { type: Object, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
return () => h("div", { "data-workbench-host-stub": "" }, `${props.plugin.manifest.name} · ${props.contribution.label}`);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import PluginBottomDock from "./PluginBottomDock.vue";
|
||||
import * as dock from "@/lib/plugins/pluginBottomDock";
|
||||
|
||||
function installedPlugin(contributionId: string): InstalledPlugin {
|
||||
return {
|
||||
manifest: { id: "io.dbx.sample", name: "Sample", version: "1.0.0", permissions: [], drivers: [], contributions: [{ type: "workbench", id: contributionId, label: "Sample panel" }] },
|
||||
compatibility: { compatible: true },
|
||||
};
|
||||
}
|
||||
|
||||
describe("PluginBottomDock workbench contribution guard", () => {
|
||||
let app: App<Element> | undefined;
|
||||
let root: HTMLDivElement;
|
||||
let renderError: unknown;
|
||||
const entryIds: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root.remove();
|
||||
for (const id of entryIds.splice(0)) dock.closePluginDockEntry(id);
|
||||
dock.setDockVisible(false);
|
||||
});
|
||||
|
||||
async function mountDock() {
|
||||
dock.setDockVisible(true);
|
||||
const application = createApp(PluginBottomDock);
|
||||
renderError = undefined;
|
||||
application.config.errorHandler = (error) => {
|
||||
renderError = error;
|
||||
};
|
||||
app = application;
|
||||
application.mount(root);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
it("renders the workbench host when the entry's contribution is still live", async () => {
|
||||
mocks.listPlugins.mockResolvedValue([installedPlugin("sample.panel")]);
|
||||
entryIds.push(dock.addPluginDockEntry({ pluginId: "io.dbx.sample", workbenchContributionId: "sample.panel", kind: "command", commandId: "sample.open", title: "Sample terminal" }));
|
||||
|
||||
await mountDock();
|
||||
|
||||
expect(renderError).toBeUndefined();
|
||||
expect(root.querySelector("[data-workbench-host-stub]")?.textContent).toBe("Sample · Sample panel");
|
||||
});
|
||||
|
||||
it("degrades to an empty frame instead of crashing when the plugin no longer declares the entry's workbench contribution", async () => {
|
||||
// Plugin upgrade/downgrade replaced the workbench id the entry was opened with.
|
||||
mocks.listPlugins.mockResolvedValue([installedPlugin("sample.renamed")]);
|
||||
entryIds.push(dock.addPluginDockEntry({ pluginId: "io.dbx.sample", workbenchContributionId: "sample.panel", kind: "command", commandId: "sample.open", title: "Sample terminal" }));
|
||||
|
||||
await mountDock();
|
||||
|
||||
expect(renderError).toBeUndefined();
|
||||
expect(root.querySelector("[data-workbench-host-stub]")).toBeNull();
|
||||
// The tab strip (and its close affordance) survives so the stale entry stays closable.
|
||||
expect(root.querySelector("[data-plugin-dock-tabs]")?.textContent).toContain("Sample terminal");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
<script setup lang="ts">
|
||||
// PR-A4/P2 global bottom panel dock (HOST_PLUGIN_UI_SPEC §8.3) — a generic host container:
|
||||
// it only provides the panel frame (tab strip, drag-resize height, collapse/maximize/hide) and hosts any plugin's
|
||||
// panel webviews, with zero plugin business inside; multi-terminal/shell selection/connection switching all live in the plugin
|
||||
// the plugin's own panel page via the bridge openWorkbench, which adds another dock entry).
|
||||
// Each entry owns a host-stable workbenchId; v-show keeps sessions alive while switching tabs.
|
||||
import { computed, nextTick, onScopeDispose, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ChevronDown, ChevronUp, Maximize2, Minimize2, Plus, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import PluginWorkbenchHost from "@/components/plugins/PluginWorkbenchHost.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import {
|
||||
activatePluginDockEntry,
|
||||
addPluginDockEntry,
|
||||
closePluginDockEntry,
|
||||
DOCK_MAX_VIEWPORT_RATIO,
|
||||
DOCK_MIN_HEIGHT_PX,
|
||||
movePluginDockEntry,
|
||||
persistDockHeight,
|
||||
renamePluginDockEntry,
|
||||
restoreDockHeight,
|
||||
setDockMaximized,
|
||||
setDockVisible,
|
||||
usePluginBottomDock,
|
||||
type PluginDockEntry,
|
||||
} from "@/lib/plugins/pluginBottomDock";
|
||||
import { useDockResize } from "@/composables/useDockResize";
|
||||
import { executePluginCommand } from "@/lib/plugins/pluginCommandRegistry";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
// Tab strip interactions (drag reorder + double-click rename).
|
||||
const dragEntryId = ref<string | null>(null);
|
||||
const renamingEntryId = ref<string | null>(null);
|
||||
const renameDraft = ref("");
|
||||
const renameInput = ref<HTMLInputElement | null>(null);
|
||||
function setRenameInputRef(element: unknown) {
|
||||
renameInput.value = element as HTMLInputElement | null;
|
||||
}
|
||||
watch(renamingEntryId, async (id) => {
|
||||
if (!id) return;
|
||||
await nextTick();
|
||||
renameInput.value?.select();
|
||||
});
|
||||
function startRename(entry: PluginDockEntry) {
|
||||
renamingEntryId.value = entry.id;
|
||||
renameDraft.value = entry.title;
|
||||
}
|
||||
function commitRename() {
|
||||
if (!renamingEntryId.value) return;
|
||||
renamePluginDockEntry(renamingEntryId.value, renameDraft.value);
|
||||
renamingEntryId.value = null;
|
||||
}
|
||||
function cancelRename() {
|
||||
renamingEntryId.value = null;
|
||||
}
|
||||
function onTabDragStart(entry: PluginDockEntry, event: DragEvent) {
|
||||
dragEntryId.value = entry.id;
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData("text/plain", entry.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
}
|
||||
// Live reorder while hovering the target tab: the dragged entry jumps to the
|
||||
// target's slot immediately, which keeps the gesture simple and predictable.
|
||||
function onTabDragOver(entry: PluginDockEntry) {
|
||||
if (!dragEntryId.value || dragEntryId.value === entry.id) return;
|
||||
const toIndex = entries.value.findIndex((candidate) => candidate.id === entry.id);
|
||||
if (toIndex < 0) return;
|
||||
movePluginDockEntry(dragEntryId.value, toIndex);
|
||||
}
|
||||
function onTabDragEnd() {
|
||||
dragEntryId.value = null;
|
||||
}
|
||||
const queryStore = useQueryStore();
|
||||
const { entries, activeEntryId, visible, maximized } = usePluginBottomDock();
|
||||
const collapsed = ref(false);
|
||||
// The dragged height persists as UI chrome (§8.4 is untouched: dock entries
|
||||
// themselves are never restored across restarts).
|
||||
const dockHeight = ref(restoreDockHeight(window.innerHeight));
|
||||
watch(dockHeight, (height) => persistDockHeight(height));
|
||||
const plugins = ref<InstalledPlugin[]>([]);
|
||||
|
||||
const activeEntry = computed(() => entries.value.find((entry) => entry.id === activeEntryId.value) ?? null);
|
||||
const activeCommand = computed(() => {
|
||||
const entry = activeEntry.value;
|
||||
// Connection entries carry their source commandId too, so the "+" picker
|
||||
// (replay / launch options / connection targets) is available on every panel.
|
||||
if (!entry || !entry.commandId) return null;
|
||||
return createFrontendPluginRegistry(plugins.value).findCommand(entry.pluginId, entry.commandId)?.contribution ?? null;
|
||||
});
|
||||
|
||||
watch(entries, () => void loadPluginData(), { deep: true, immediate: true });
|
||||
watch([activeEntry, activeCommand], () => void loadLaunchOptions());
|
||||
const onPluginsChanged = () => void loadPluginData();
|
||||
window.addEventListener("dbx:plugins-changed", onPluginsChanged);
|
||||
onScopeDispose(() => window.removeEventListener("dbx:plugins-changed", onPluginsChanged));
|
||||
|
||||
async function loadPluginData() {
|
||||
if (!entries.value.length) {
|
||||
plugins.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
plugins.value = await api.listPlugins();
|
||||
} catch {
|
||||
plugins.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function definitionFor(pluginId: string): InstalledPlugin | undefined {
|
||||
return plugins.value.find((candidate) => candidate.manifest.id === pluginId);
|
||||
}
|
||||
|
||||
// A live entry can outlive its contribution (plugin upgrade/downgrade changed
|
||||
// the workbench id, or dropped the contribution entirely). The host renders
|
||||
// props.contribution.label unconditionally, so return null and let the v-if
|
||||
// degrade the panel to an empty frame instead of crashing — same degradation
|
||||
// as the missing-plugin case; the tab (and its close button) stays available.
|
||||
function workbenchContributionFor(entry: PluginDockEntry): PluginWorkbenchContribution | null {
|
||||
const definition = definitionFor(entry.pluginId);
|
||||
if (!definition) return null;
|
||||
return (definition.manifest.contributions || []).find((candidate): candidate is PluginWorkbenchContribution => candidate.type === "workbench" && candidate.id === entry.workbenchContributionId) ?? null;
|
||||
}
|
||||
|
||||
// Generic "+" picker (extension-point driven, zero business in the host):
|
||||
// - the command itself (replay),
|
||||
// - dynamic entries from the declared sidecar options_action (e.g. shell types),
|
||||
// - when connection_targets is on, the plugin's own saved connections.
|
||||
const connectionStore = useConnectionStore();
|
||||
const launchOptionEntries = ref<Array<{ key: string; label: string; description?: string; context?: Record<string, unknown> }>>([]);
|
||||
const launchOptionsLoading = ref(false);
|
||||
|
||||
const activeAction = computed(() => (activeCommand.value?.action.type === "open-workbench" ? activeCommand.value.action : null));
|
||||
const activePluginProviders = computed(() => {
|
||||
const pluginId = activeEntry.value?.pluginId;
|
||||
const plugin = pluginId ? definitionFor(pluginId) : undefined;
|
||||
return new Set((plugin?.manifest.contributions || []).filter((candidate) => candidate.type === "connection-provider").map((candidate) => candidate.id));
|
||||
});
|
||||
|
||||
async function loadLaunchOptions() {
|
||||
const action = activeAction.value;
|
||||
const pluginId = activeEntry.value?.pluginId;
|
||||
launchOptionEntries.value = [];
|
||||
if (!action || !pluginId || !action.options_action) return;
|
||||
launchOptionsLoading.value = true;
|
||||
try {
|
||||
const result = await api.invokePlugin<{ entries?: Array<{ label: string; description?: string; context?: Record<string, unknown> }> }>(pluginId, action.options_action, {});
|
||||
launchOptionEntries.value = (result?.entries ?? []).map((entry, index) => ({ key: `opt:${index}`, label: entry.label, description: entry.description, context: entry.context }));
|
||||
} catch (cause) {
|
||||
console.warn("[DBX][plugin:dock] launch options unavailable", cause);
|
||||
launchOptionEntries.value = [];
|
||||
} finally {
|
||||
launchOptionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const connectionTargets = computed(() => {
|
||||
if (!activeAction.value?.connection_targets) return [];
|
||||
const providers = activePluginProviders.value;
|
||||
return connectionStore.connections.filter((connection) => providers.has(connection.plugin_connection_provider ?? "")).map((connection) => ({ key: `conn:${connection.id}`, label: connection.name || connection.id, connection }));
|
||||
});
|
||||
|
||||
function onPlusAction(value: string) {
|
||||
if (value === "replay") {
|
||||
rerunActiveCommand();
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("opt:")) {
|
||||
const index = Number(value.slice(4));
|
||||
const option = launchOptionEntries.value[index];
|
||||
if (!option) return;
|
||||
const entry = activeEntry.value;
|
||||
const command = activeCommand.value;
|
||||
if (!entry || !command) return;
|
||||
const id = addPluginDockEntry({
|
||||
pluginId: entry.pluginId,
|
||||
workbenchContributionId: entry.workbenchContributionId,
|
||||
kind: "command",
|
||||
commandId: command.id,
|
||||
title: option.label,
|
||||
icon: command.icon,
|
||||
commandContext: option.context ?? {},
|
||||
});
|
||||
activatePluginDockEntry(id);
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("conn:")) {
|
||||
const connectionId = value.slice("conn:".length);
|
||||
const target = connectionTargets.value.find((candidate) => candidate.key === `conn:${connectionId}`);
|
||||
const connection = target?.connection;
|
||||
const entry = activeEntry.value;
|
||||
const command = activeCommand.value;
|
||||
if (!connection || !entry || !command) return;
|
||||
const id = addPluginDockEntry({
|
||||
pluginId: entry.pluginId,
|
||||
workbenchContributionId: entry.workbenchContributionId,
|
||||
kind: "connection",
|
||||
commandId: command.id,
|
||||
title: connection.name || connection.id,
|
||||
icon: activeCommand.value?.icon,
|
||||
commandContext: {
|
||||
connectionId: connection.id,
|
||||
providerId: connection.plugin_connection_provider,
|
||||
connectionType: connection.plugin_connection_type,
|
||||
// §8.3 面板加载生命周期:标记宿主已在点击时预拨号,插件面板就绪后
|
||||
// 跳过 force 重开、直接开会话。
|
||||
connectionPreconnected: true,
|
||||
connection: {
|
||||
id: connection.id,
|
||||
name: connection.name,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
username: connection.username,
|
||||
readOnly: connection.read_only === true,
|
||||
},
|
||||
},
|
||||
});
|
||||
// 点击即拨号:SSH 握手与面板 webview 引导(数秒)并行,面板就绪即会话就绪。
|
||||
// 无 force:同连接已有存活会话时这是健康检查级 no-op,不会打断它。
|
||||
void useConnectionStore()
|
||||
.ensureConnected(connection.id, { activate: false })
|
||||
.catch(() => undefined);
|
||||
activatePluginDockEntry(id);
|
||||
}
|
||||
}
|
||||
|
||||
function rerunActiveCommand() {
|
||||
const entry = activeEntry.value;
|
||||
const command = activeCommand.value;
|
||||
if (!entry || !command) return;
|
||||
const result = executePluginCommand(createFrontendPluginRegistry(plugins.value), queryStore, entry.pluginId, command.id);
|
||||
if (result.error) console.warn("[DBX][plugin:dock]", result.error);
|
||||
}
|
||||
|
||||
// A dock-hosted webview asking for another panel via the bridge openWorkbench: the host rebuilds the authoritative
|
||||
// context (dropping plugin-supplied reserved fields) and adds one generic panel entry.
|
||||
function onPanelOpenWorkbench(entry: (typeof entries.value)[number], _contributionId: string, childContext?: Record<string, unknown>) {
|
||||
const payload = childContext && typeof childContext === "object" && !Array.isArray(childContext) ? { ...childContext } : {};
|
||||
delete payload.workbenchId;
|
||||
delete payload.restored;
|
||||
delete payload.surface;
|
||||
const id = addPluginDockEntry({
|
||||
pluginId: entry.pluginId,
|
||||
workbenchContributionId: entry.workbenchContributionId,
|
||||
kind: "command",
|
||||
title: entry.title,
|
||||
commandContext: payload,
|
||||
});
|
||||
activatePluginDockEntry(id);
|
||||
}
|
||||
|
||||
// §8.3/§7.4 two-phase close: removing an entry first asks its panel webview to
|
||||
// release the workbench scope (PTY sessions, subscriptions) and waits for the
|
||||
// bridge-bounded handshake before the iframe is unmounted.
|
||||
const workbenchHosts = new Map<string, { requestClose: () => Promise<boolean> }>();
|
||||
function setWorkbenchHostRef(entryId: string) {
|
||||
return (element: unknown) => {
|
||||
const host = element as { requestClose: () => Promise<boolean> } | null;
|
||||
if (host) workbenchHosts.set(entryId, host);
|
||||
else workbenchHosts.delete(entryId);
|
||||
};
|
||||
}
|
||||
|
||||
async function closeEntry(entryId: string) {
|
||||
try {
|
||||
await workbenchHosts.get(entryId)?.requestClose();
|
||||
} catch {
|
||||
// Teardown proceeds regardless of a broken handshake.
|
||||
}
|
||||
closePluginDockEntry(entryId);
|
||||
}
|
||||
|
||||
// Hide the panel: terminal sessions survive (VS Code semantics); the toolbar icon restores it.
|
||||
function hideDock() {
|
||||
collapsed.value = false;
|
||||
setDockMaximized(false);
|
||||
setDockVisible(false);
|
||||
}
|
||||
|
||||
// Drag the top edge to resize the height (min 140px, up to the shared maximize
|
||||
// bound). Dragging always exits maximized/collapsed: the start height is the
|
||||
// currently rendered pixel height, so the transition is seamless. The
|
||||
// composable owns pointer capture, rAF coalescing and listener cleanup — see
|
||||
// useDockResize for why capture is load-bearing over the plugin iframes.
|
||||
const dockRoot = ref<HTMLElement>();
|
||||
const { startResize } = useDockResize({
|
||||
dockHeight,
|
||||
minHeight: DOCK_MIN_HEIGHT_PX,
|
||||
maxHeightRatio: DOCK_MAX_VIEWPORT_RATIO,
|
||||
dockElement: () => dockRoot.value ?? null,
|
||||
});
|
||||
|
||||
function onResizeHandlePointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0) return;
|
||||
// Leave maximized/collapsed before the drag measures the rendered height:
|
||||
// the composable starts from the currently rendered px height, so the
|
||||
// transition stays seamless.
|
||||
setDockMaximized(false);
|
||||
collapsed.value = false;
|
||||
startResize(event);
|
||||
}
|
||||
|
||||
// "+" picker menu: anchored below the + button, bottom-stuck inside the dock,
|
||||
// growing upward with content (capped by the dock body), closed by selecting
|
||||
// an item or clicking anywhere else.
|
||||
const plusOpen = ref(false);
|
||||
// Generic list filter for the "+" picker: purely client-side label matching so
|
||||
// any plugin's long option/target list stays usable without the host knowing
|
||||
// what the entries mean.
|
||||
const plusFilter = ref("");
|
||||
const PLUS_FILTER_THRESHOLD = 8;
|
||||
const plusItemCount = computed(() => 1 + launchOptionEntries.value.length + connectionTargets.value.length);
|
||||
const plusQuery = computed(() => plusFilter.value.trim().toLowerCase());
|
||||
function plusMatches(label: string): boolean {
|
||||
return !plusQuery.value || label.toLowerCase().includes(plusQuery.value);
|
||||
}
|
||||
const visibleLaunchOptions = computed(() => launchOptionEntries.value.filter((option) => plusMatches(option.label)));
|
||||
const visibleConnectionTargets = computed(() => connectionTargets.value.filter((target) => plusMatches(target.label)));
|
||||
const plusRoot = ref<HTMLElement>();
|
||||
function togglePlusMenu() {
|
||||
plusOpen.value = !plusOpen.value;
|
||||
plusFilter.value = "";
|
||||
if (plusOpen.value) void loadLaunchOptions();
|
||||
}
|
||||
function closePlusMenu() {
|
||||
plusOpen.value = false;
|
||||
}
|
||||
const onPlusMenuOutsidePointerDown = (event: PointerEvent) => {
|
||||
const root = plusRoot.value;
|
||||
if (plusOpen.value && root && !root.contains(event.target as Node)) closePlusMenu();
|
||||
};
|
||||
window.addEventListener("pointerdown", onPlusMenuOutsidePointerDown, true);
|
||||
onScopeDispose(() => window.removeEventListener("pointerdown", onPlusMenuOutsidePointerDown, true));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- v-show, not v-if (HOST_PLUGIN_UI_SPEC §8.3): hiding the panel must only
|
||||
hide the UI — the entry webviews (and the user's dragged height) stay
|
||||
mounted and alive across hide/show. -->
|
||||
<div v-show="visible" ref="dockRoot" data-plugin-bottom-dock class="relative z-10 flex shrink-0 flex-col overflow-hidden border-t bg-background" :style="{ height: maximized ? `${DOCK_MAX_VIEWPORT_RATIO * 100}vh` : collapsed ? '2.25rem' : `${dockHeight}px` }">
|
||||
<div data-plugin-dock-resize-handle class="absolute inset-x-0 top-0 z-10 h-1.5 cursor-row-resize hover:bg-primary/30" @pointerdown="onResizeHandlePointerDown" />
|
||||
<div class="flex h-9 shrink-0 items-center gap-1 border-b bg-muted/30 pl-2 pr-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto" data-plugin-dock-tabs>
|
||||
<button
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="group flex h-7 min-w-0 max-w-40 shrink items-center gap-1 overflow-hidden rounded-md px-2 text-xs"
|
||||
:class="[entry.id === activeEntryId ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground', dragEntryId && dragEntryId !== entry.id ? 'opacity-60' : '']"
|
||||
:title="entry.title"
|
||||
:draggable="renamingEntryId !== entry.id"
|
||||
@click="activatePluginDockEntry(entry.id)"
|
||||
@dblclick="startRename(entry)"
|
||||
@dragstart="onTabDragStart(entry, $event)"
|
||||
@dragover.prevent="onTabDragOver(entry)"
|
||||
@dragend="onTabDragEnd"
|
||||
@drop.prevent="onTabDragEnd"
|
||||
>
|
||||
<PluginIcon :plugin-id="entry.pluginId" :icon="entry.icon" class="h-3.5 w-3.5 shrink-0" />
|
||||
<input
|
||||
v-if="renamingEntryId === entry.id"
|
||||
:ref="setRenameInputRef"
|
||||
v-model="renameDraft"
|
||||
class="min-w-0 flex-1 bg-transparent text-xs outline-none"
|
||||
spellcheck="false"
|
||||
@click.stop
|
||||
@dblclick.stop
|
||||
@keydown.enter.prevent="commitRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="commitRename"
|
||||
/>
|
||||
<span v-else class="min-w-0 flex-1 truncate text-left">{{ entry.title }}</span>
|
||||
<span v-if="renamingEntryId !== entry.id" class="ml-0.5 shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-background/80 group-hover:opacity-100" role="button" :aria-label="t('pluginDock.close')" @click.stop="closeEntry(entry.id)">
|
||||
<X class="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="activeCommand" ref="plusRoot" class="relative">
|
||||
<Tooltip :delay-duration="200">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :aria-label="t('pluginDock.newTerminal')" :aria-expanded="plusOpen" @click="togglePlusMenu">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("pluginDock.newTerminal") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div v-if="plusOpen" data-plugin-dock-plus-menu class="absolute right-0 top-full z-30 mt-1 max-h-[50vh] w-64 overflow-y-auto rounded-md border bg-background p-1 shadow-lg" role="menu">
|
||||
<!-- Generic list filter (appears only for long lists): the host filters
|
||||
by label without knowing what the entries mean. -->
|
||||
<input v-if="plusItemCount > PLUS_FILTER_THRESHOLD" v-model="plusFilter" class="mb-1 w-full rounded-md border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-primary/40" :placeholder="t('pluginDock.filter')" spellcheck="false" @keydown.stop />
|
||||
<button v-if="plusMatches(t('pluginDock.newTerminal'))" class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted" role="menuitem" @click="onPlusAction('replay')">
|
||||
<Plus class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ t("pluginDock.newTerminal") }}</span>
|
||||
</button>
|
||||
<button v-for="option in visibleLaunchOptions" :key="option.key" class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted" role="menuitem" @click="onPlusAction(option.key)">
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
</button>
|
||||
<button v-for="target in visibleConnectionTargets" :key="target.key" class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted" role="menuitem" :title="target.connection.name" @click="onPlusAction(target.key)">
|
||||
<span class="truncate">{{ t("pluginDock.connectionTerminal") }} · {{ target.label }}</span>
|
||||
</button>
|
||||
<div v-if="plusQuery && !plusMatches(t('pluginDock.newTerminal')) && !visibleLaunchOptions.length && !visibleConnectionTargets.length" class="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{{ t("pluginDock.noMatch") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip :delay-duration="200">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="maximized ? t('pluginDock.restore') : t('pluginDock.maximize')" :aria-label="maximized ? t('pluginDock.restore') : t('pluginDock.maximize')" @click="setDockMaximized(!maximized)">
|
||||
<Minimize2 v-if="maximized" class="h-3.5 w-3.5" />
|
||||
<Maximize2 v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ maximized ? t("pluginDock.restore") : t("pluginDock.maximize") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="collapsed ? t('pluginDock.expand') : t('pluginDock.collapse')" :aria-label="collapsed ? t('pluginDock.expand') : t('pluginDock.collapse')" @click="collapsed = !collapsed">
|
||||
<ChevronUp v-if="collapsed" class="h-3.5 w-3.5" />
|
||||
<ChevronDown v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('pluginDock.hide')" :aria-label="t('pluginDock.hide')" @click="hideDock">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<div v-for="entry in entries" v-show="entry.id === activeEntryId && !collapsed" :key="entry.id" class="h-full w-full">
|
||||
<PluginWorkbenchHost
|
||||
v-if="workbenchContributionFor(entry)"
|
||||
:ref="setWorkbenchHostRef(entry.id)"
|
||||
:plugin="definitionFor(entry.pluginId)!"
|
||||
:contribution="workbenchContributionFor(entry)!"
|
||||
:context="entry.context"
|
||||
@close-tab="closeEntry(entry.id)"
|
||||
@open-workbench="(_pluginId, contributionId, context) => onPanelOpenWorkbench(entry, contributionId, context)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,6 +18,7 @@ import { loadPinnedPluginIds, savePinnedPluginIds, sortPluginsPinnedFirst } from
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
|
||||
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
|
||||
import { executePluginCommand } from "@/lib/plugins/pluginCommandRegistry";
|
||||
import {
|
||||
beaconPluginInstall,
|
||||
buildInstalledUpdateIndex,
|
||||
@@ -135,6 +136,8 @@ const selectedEntry = computed(() => connectionProviders.value.find((entry) => e
|
||||
const selectedDefinition = computed(() => definitions.value.find((definition) => definition.plugin.manifest.id === selectedPluginId.value) || null);
|
||||
const selectedWorkbenches = computed(() => registry.value.listWorkbenches().filter((entry) => entry.plugin.manifest.id === selectedPluginId.value));
|
||||
const selectedFilesystems = computed(() => registry.value.listFilesystemProviders().filter((entry) => entry.plugin.manifest.id === selectedPluginId.value));
|
||||
// PR-A4: plugins declaring commands drive their quick entries via commands (workbench opens route to the command; the SFTP browse entry is retired).
|
||||
const selectedHasCommands = computed(() => registry.value.listCommands().some((entry) => entry.plugin.manifest.id === selectedPluginId.value));
|
||||
const providerConnections = computed(() => {
|
||||
const entry = selectedEntry.value;
|
||||
if (!entry) return [];
|
||||
@@ -294,6 +297,7 @@ async function installMarketplaceListing(listing: MarketplacePluginListing, opti
|
||||
// installed list); notifyComponentUpdatesChanged drives the App-level update-center badge.
|
||||
notifyComponentPluginsUpdated();
|
||||
notifyComponentUpdatesChanged();
|
||||
window.dispatchEvent(new CustomEvent("dbx:plugins-changed"));
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
} catch (cause) {
|
||||
toast(translateBackendError(t, cause), 8000);
|
||||
@@ -320,6 +324,7 @@ function reportBatchSummary(outcome: { succeeded: unknown[]; failed: { name: str
|
||||
async function refreshAfterBatch() {
|
||||
try {
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
window.dispatchEvent(new CustomEvent("dbx:plugins-changed"));
|
||||
} catch (cause) {
|
||||
error.value = [error.value, t("pluginPlatform.batchRefreshFailed", { error: cause instanceof Error ? cause.message : String(cause) })].filter(Boolean).join("\n");
|
||||
}
|
||||
@@ -610,6 +615,14 @@ async function openFilesystem(pluginId: string, providerId: string, label: strin
|
||||
}
|
||||
|
||||
function openWorkbench(pluginId: string, contributionId: string, label: string) {
|
||||
// PR-A4: when the plugin declares a command targeting this workbench, the entry opens through it
|
||||
// host-authored context — the SSH plugin lands directly in the local terminal); otherwise the legacy behavior applies.
|
||||
const command = registry.value.findCommandTargetingWorkbench(pluginId, contributionId);
|
||||
if (command) {
|
||||
const result = executePluginCommand(registry.value, queryStore, pluginId, command.id);
|
||||
if (result.error) toast(result.error, 5000);
|
||||
return;
|
||||
}
|
||||
const connection = selectedConnection.value;
|
||||
queryStore.openPluginWorkbench(pluginId, contributionId, {
|
||||
title: connection?.name || label,
|
||||
@@ -666,6 +679,7 @@ async function finishInstall(result: PluginInstallResult) {
|
||||
notifyComponentPluginsUpdated();
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
notifyComponentUpdatesChanged();
|
||||
window.dispatchEvent(new CustomEvent("dbx:plugins-changed"));
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
activeSection.value = "installed";
|
||||
}
|
||||
@@ -813,6 +827,7 @@ async function rollbackSelectedPlugin() {
|
||||
clearPluginIconCache();
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
notifyComponentUpdatesChanged();
|
||||
window.dispatchEvent(new CustomEvent("dbx:plugins-changed"));
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
} catch (cause) {
|
||||
toast(translateBackendError(t, cause), 8000);
|
||||
@@ -828,6 +843,7 @@ async function uninstallSelectedPlugin() {
|
||||
try {
|
||||
installedPlugins.value = await api.uninstallPlugin(definition.plugin.manifest.id);
|
||||
notifyComponentUpdatesChanged();
|
||||
window.dispatchEvent(new CustomEvent("dbx:plugins-changed"));
|
||||
clearPluginIconCache();
|
||||
toast(t("pluginPlatform.uninstallSuccess", { name: definition.plugin.manifest.name }));
|
||||
selectFirstProvider();
|
||||
@@ -1341,7 +1357,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedFilesystems.length" class="space-y-2">
|
||||
<div v-if="selectedFilesystems.length && !selectedHasCommands" class="space-y-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ t("pluginPlatform.filesystemProviders") }}</div>
|
||||
<div v-for="entry in selectedFilesystems" :key="entry.contribution.id" class="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { buildPluginEditorAppearance } from "@/lib/plugins/pluginAppearance";
|
||||
import { downloadPluginFile, cancelPluginDownload } from "@/lib/plugins/pluginFileDownload";
|
||||
import type { InstalledPlugin, PluginUiContribution } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { getCachedPluginUiHtml, setCachedPluginUiHtml } from "@/lib/plugins/pluginUiHtmlCache";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -379,6 +380,20 @@ function createBridge() {
|
||||
openWorkbench: async (pluginId, contributionId, context, options) => emit("openWorkbench", pluginId, contributionId, context, options),
|
||||
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
|
||||
reopenConnection: (pluginId, connectionId) => useConnectionStore().reopenPluginConnection(connectionId, pluginId),
|
||||
// PR-A4 generic extension point: a read-only, secret-free, plugin-scoped connection list (for in-panel connection switching).
|
||||
listConnections: (ownerPluginId) => {
|
||||
const providerIds = new Set((props.plugin.manifest.contributions || []).filter((candidate) => candidate.type === "connection-provider").map((candidate) => candidate.id));
|
||||
if (ownerPluginId !== props.plugin.manifest.id) return [];
|
||||
return useConnectionStore()
|
||||
.connections.filter((connection) => providerIds.has(connection.plugin_connection_provider ?? ""))
|
||||
.map((connection) => ({
|
||||
id: connection.id,
|
||||
name: connection.name,
|
||||
providerId: connection.plugin_connection_provider ?? "",
|
||||
connectionType: connection.plugin_connection_type,
|
||||
readOnly: connection.read_only === true,
|
||||
}));
|
||||
},
|
||||
// Both plan calls carry the plugin's declared `host.plans:read` gate in the
|
||||
// bridge; the backend owns EXPLAIN generation, the timeout, and the plan cap.
|
||||
getPlanCapabilities: (connectionId) => api.getPluginPlanCapabilities(connectionId),
|
||||
@@ -468,19 +483,34 @@ function localUiAssetPath(source: string): string | undefined {
|
||||
}
|
||||
|
||||
async function inlineLocalUiAssets(html: string, pluginId: string): Promise<{ html: string; entryDirectory: string }> {
|
||||
// Shipped ui builds usually inline every asset into one HTML document.
|
||||
// Parsing and re-serializing a multi-megabyte document is pure overhead when
|
||||
// there is nothing local to inline — pre-check before touching DOMParser.
|
||||
if (!/<script\b[^>]*\bsrc=/i.test(html) && !/<link\b[^>]*rel=["']?stylesheet/i.test(html)) {
|
||||
return { html, entryDirectory: "" };
|
||||
}
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const resources = [...document.querySelectorAll("script[src], link[rel='stylesheet'][href]")];
|
||||
// Dynamic-import chunks and CSS url() references live next to the entry
|
||||
// script; its directory is the <base> the sandbox document needs to resolve
|
||||
// them through the dbx-plugin scheme.
|
||||
let entryDirectory = "";
|
||||
for (const resource of resources) {
|
||||
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
|
||||
const path = source ? localUiAssetPath(source) : undefined;
|
||||
if (!path) continue;
|
||||
if (!entryDirectory) entryDirectory = path.split("/").slice(0, -1).join("/");
|
||||
const asset = await api.readPluginUiAsset(pluginId, path);
|
||||
const content = new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0)));
|
||||
// Fetch every referenced asset concurrently — these are bridge round-trips
|
||||
// into the sidecar, and panels reopen this path on every workbench (re)load.
|
||||
const fetched = await Promise.all(
|
||||
resources.map((resource) => {
|
||||
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
|
||||
const path = source ? localUiAssetPath(source) : undefined;
|
||||
if (!path) return Promise.resolve({ resource, content: null });
|
||||
if (!entryDirectory) entryDirectory = path.split("/").slice(0, -1).join("/");
|
||||
return api.readPluginUiAsset(pluginId, path).then((asset) => ({
|
||||
resource,
|
||||
content: new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0))),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
for (const { resource, content } of fetched) {
|
||||
if (content === null) continue;
|
||||
if (resource.tagName === "SCRIPT") {
|
||||
const script = document.createElement("script");
|
||||
for (const attribute of [...resource.attributes]) {
|
||||
@@ -509,6 +539,8 @@ function pluginUiBaseUrl(pluginId: string, entryDirectory: string): string | und
|
||||
return entryDirectory ? `${origin}${entryDirectory}/` : origin;
|
||||
}
|
||||
|
||||
// Inlined plugin ui html per `${pluginId}:${version}` (see loadWorkbench).
|
||||
|
||||
async function loadWorkbench() {
|
||||
const generation = ++loadGeneration;
|
||||
bridge?.dispose();
|
||||
@@ -518,11 +550,23 @@ async function loadWorkbench() {
|
||||
error.value = "";
|
||||
try {
|
||||
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
|
||||
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
|
||||
const { html, entryDirectory } = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
// The read/decode/inline pipeline over a multi-megabyte ui build dominates
|
||||
// workbench open time; cache the inlined html per plugin id+version so
|
||||
// reopening panels (new dock entries, workbench reloads) skips it. Theme
|
||||
// is applied per load via the sandbox document, so the cache never pins a
|
||||
// stale appearance.
|
||||
const htmlCacheKey = `${props.plugin.manifest.id}:${props.plugin.manifest.version}`;
|
||||
let cachedHtml = getCachedPluginUiHtml(htmlCacheKey);
|
||||
if (!cachedHtml) {
|
||||
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
|
||||
const inlined = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
cachedHtml = inlined;
|
||||
setCachedPluginUiHtml(htmlCacheKey, cachedHtml);
|
||||
}
|
||||
const { html, entryDirectory } = cachedHtml;
|
||||
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions, currentBridgeTheme(), {
|
||||
baseUrl: pluginUiBaseUrl(props.plugin.manifest.id, entryDirectory),
|
||||
});
|
||||
@@ -596,9 +640,22 @@ watch(
|
||||
() => bridge?.updateTheme(currentBridgeTheme()),
|
||||
);
|
||||
|
||||
/** §8.3/§7.4 two-phase close: parents await this before removing the entry so
|
||||
* the plugin can release its workbench scope (PTY sessions, subscriptions);
|
||||
* the bridge bounds the wait and resolves false on legacy/hung plugins. */
|
||||
function requestClose(): Promise<boolean> {
|
||||
return bridge ? bridge.requestWorkbenchClose() : Promise.resolve(false);
|
||||
}
|
||||
|
||||
defineExpose({ requestClose });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
loadGeneration += 1;
|
||||
// Best-effort §8.3 close notice for teardown paths that never called
|
||||
// requestClose (tab closes, plugin reload): the message still goes out, but
|
||||
// delivery of the plugin's cleanup is not guaranteed once the iframe dies.
|
||||
void bridge?.requestWorkbenchClose(0).catch(() => undefined);
|
||||
bridge?.dispose();
|
||||
bridge = undefined;
|
||||
window.removeEventListener("message", onMessage);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import { useQuickOpen, type QuickOpenItem } from "@/composables/useQuickOpen";
|
||||
import { usePluginCommandPalette } from "@/lib/plugins/pluginCommandPalette";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
|
||||
import { getGlobalSearchRoots, saveGlobalSearchRoots, getGlobalSearchExtensions, saveGlobalSearchExtensions } from "@/lib/globalSearch/globalSearchSettings";
|
||||
@@ -22,7 +24,11 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { searchQuery, filteredItems, selectedIndex, selectedItem, selectNext, selectPrevious, setQuery, loadExternalSqlFiles, loadPluginWorkbenches, contentMode, contentGroups, contentSelectedItem, contentSearching, setContentMode } = useQuickOpen();
|
||||
// Plugin commandPalette commands (HOST_PLUGIN_UI_SPEC §5): injected as extra quick-open entries;
|
||||
// list refresh is driven by dbx:plugins-changed / focus / locale change, with a fallback refresh when the dialog opens.
|
||||
const pluginPalette = usePluginCommandPalette();
|
||||
const { toast } = useToast();
|
||||
const { searchQuery, filteredItems, selectedIndex, selectedItem, selectNext, selectPrevious, setQuery, loadExternalSqlFiles, loadPluginWorkbenches, contentMode, contentGroups, contentSelectedItem, contentSearching, setContentMode } = useQuickOpen({ extraItems: pluginPalette.items });
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -129,6 +135,14 @@ function handleKeyDown(e: KeyboardEvent): void {
|
||||
}
|
||||
|
||||
function handleSelect(item: QuickOpenItem): void {
|
||||
// Plugin commands execute inside this component: they are NOT dispatched to App.vue object navigation (that chain
|
||||
// connectionId-based connection/expand logic is meaningless for command entries); execution errors surface via the existing toast.
|
||||
if (item.type === "plugin_command") {
|
||||
const result = pluginPalette.open(item);
|
||||
if (result.error) toast(result.error, 5000);
|
||||
dialogOpen.value = false;
|
||||
return;
|
||||
}
|
||||
emit("select", item);
|
||||
dialogOpen.value = false;
|
||||
}
|
||||
@@ -212,6 +226,12 @@ function getItemIcon(type: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Right badge copy: plugin commands show the source plugin name as provenance; others keep the type label. */
|
||||
function getTypeBadge(item: QuickOpenItem): string {
|
||||
if (item.type === "plugin_command") return item.pluginName || item.pluginId || "";
|
||||
return getTypeLabel(item.type);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(newOpen) => {
|
||||
@@ -220,6 +240,8 @@ watch(
|
||||
setContentMode(props.initialContentMode === true);
|
||||
searchSettingsOpen.value = false;
|
||||
refreshSearchSettings();
|
||||
// Fallback refresh of the plugin command list on open (regular refresh is event-driven via dbx:plugins-changed).
|
||||
void pluginPalette.refresh();
|
||||
// Eagerly load external SQL files so they appear in the initial list
|
||||
void loadExternalSqlFiles();
|
||||
// Refresh plugin workbench entries so installs/uninstalls show up without a restart
|
||||
@@ -384,7 +406,7 @@ watch(selectedIndex, async () => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs px-2 py-1 rounded bg-muted text-muted-foreground whitespace-nowrap">
|
||||
{{ getTypeLabel(item.type) }}
|
||||
{{ getTypeBadge(item) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user