fix(plugin): close plugin tabs safely and hide load flash

This commit is contained in:
t8y2
2026-09-13 13:08:15 +08:00
parent bcd1aafb33
commit 5ed03ca2b2
8 changed files with 66 additions and 13 deletions
+5
View File
@@ -3699,6 +3699,11 @@ onUnmounted(() => {
<SqlEditorWorkspace
ref="contentAreaRef"
@locate-tab="locateTabInSidebar"
@close-tab="
(tabId: string) => {
if (tabId === queryStore.activeTabId) void closeActiveSurface();
}
"
@toggle-zen-mode="toggleZenMode"
@start-resize="startTabBarResize"
@toggle-collapse="toggleTabBarCollapsed"
@@ -61,6 +61,8 @@ import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
import QueryMessagesView from "@/components/layout/QueryMessagesView.vue";
import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarActions.vue";
import PluginFilesystemTab from "@/components/plugins/PluginFilesystemTab.vue";
import PluginWorkbenchTab from "@/components/plugins/PluginWorkbenchTab.vue";
import ResultSetNavigator from "@/components/layout/ResultSetNavigator.vue";
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
@@ -123,8 +125,6 @@ const XuguServerDashboard = defineAsyncComponent(() => import("@/components/admi
const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue"));
const DamengUserAdmin = defineAsyncComponent(() => import("@/components/admin/DamengUserAdmin.vue"));
const DamengRoleAdmin = defineAsyncComponent(() => import("@/components/admin/DamengRoleAdmin.vue"));
const PluginWorkbenchTab = defineAsyncComponent(() => import("@/components/plugins/PluginWorkbenchTab.vue"));
const PluginFilesystemTab = defineAsyncComponent(() => import("@/components/plugins/PluginFilesystemTab.vue"));
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
@@ -2540,8 +2540,16 @@ defineExpose({
</template>
<template v-else-if="activeTab.mode === 'plugin-workbench' && activeTab.pluginWorkbench">
<div class="min-w-0 flex-1 min-h-0">
<PluginWorkbenchTab ref="pluginWorkbenchRef" :key="activeTab.id" :plugin-id="activeTab.pluginWorkbench.pluginId" :contribution-id="activeTab.pluginWorkbench.contributionId" :connection-id="activeTab.connectionId || undefined" :context="activeTab.pluginWorkbench.context" />
<div class="min-w-0 flex-1 min-h-0 bg-background">
<PluginWorkbenchTab
ref="pluginWorkbenchRef"
:key="activeTab.id"
:plugin-id="activeTab.pluginWorkbench.pluginId"
:contribution-id="activeTab.pluginWorkbench.contributionId"
:connection-id="activeTab.connectionId || undefined"
:context="activeTab.pluginWorkbench.context"
@close-tab="emit('closeTab', activeTab.id)"
/>
</div>
</template>
<template v-else-if="activeTab.mode === 'plugin-filesystem' && activeTab.pluginFilesystem">
@@ -61,6 +61,7 @@ export interface ContentAreaSurfaceProps {
* global active tab after an await (see redevelopment guide §6.1).
*/
export interface ContentAreaSurfaceEmits {
closeTab: [tabId: string];
"update:activeOutputView": [tabId: string, value: TabOutputView];
fixWithAi: [tabId: string, errorMessage: string];
sendSelectionToAi: [tabId: string, sql: string];
@@ -21,6 +21,7 @@ const emit = defineEmits<{
error: [message: string];
openWorkbench: [pluginId: string, contributionId: string, context?: PluginWorkbenchContext];
openFilesystem: [pluginId: string, providerId: string, context?: PluginWorkbenchContext];
closeTab: [];
}>();
const { t, locale: appLocale } = useI18n();
@@ -65,6 +66,7 @@ function createBridge() {
readAsset: api.readPluginUiAsset,
openWorkbench: async (pluginId, contributionId, context) => emit("openWorkbench", pluginId, contributionId, context),
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
closeTab: () => emit("closeTab"),
},
appLocale.value,
currentBridgeTheme(),
@@ -130,8 +132,7 @@ async function loadWorkbench() {
if (disposed || generation !== loadGeneration) return;
error.value = cause instanceof Error ? cause.message : String(cause);
emit("error", error.value);
} finally {
if (!disposed && generation === loadGeneration) loading.value = false;
loading.value = false;
}
}
@@ -142,6 +143,7 @@ function onMessage(event: MessageEvent) {
function onFrameLoad() {
bridge?.sendInit();
emit("ready");
loading.value = false;
}
onMounted(async () => {
@@ -183,14 +185,14 @@ onBeforeUnmount(() => {
<template>
<div class="relative flex size-full min-h-40 overflow-hidden bg-background">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 text-sm text-muted-foreground backdrop-blur-sm">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
</div>
<div v-else-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<div v-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error }}</span>
</div>
<iframe v-else ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
<iframe v-else-if="source" ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
<div v-if="loading && !error" class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 text-sm text-muted-foreground backdrop-blur-sm">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
</div>
</div>
</template>
@@ -19,6 +19,9 @@ const props = defineProps<{
}>();
const { t, locale: appLocale } = useI18n();
const emit = defineEmits<{
closeTab: [];
}>();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const plugins = ref<InstalledPlugin[]>([]);
@@ -102,6 +105,6 @@ defineExpose({ refresh });
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error || t("pluginPlatform.workbenchUnavailableFallback") }}</span>
</div>
<PluginWorkbenchHost v-else class="min-h-0 flex-1" :plugin="entry.plugin" :contribution="entry.contribution" :context="context" @open-workbench="openWorkbench" @open-filesystem="openFilesystem" />
<PluginWorkbenchHost v-else class="min-h-0 flex-1" :plugin="entry.plugin" :contribution="entry.contribution" :context="context" @open-workbench="openWorkbench" @open-filesystem="openFilesystem" @close-tab="emit('closeTab')" />
</div>
</template>
+21
View File
@@ -291,11 +291,32 @@ describe("PluginHostBridge", () => {
expect(document).toContain("window.dbxPlugin");
expect(document).toContain("get locale() { return locale; }");
expect(document).toContain("openFilesystem");
expect(document).toContain("shortcut: 'closeTab'");
expect(document).toContain("connect-src 'none'");
expect(document).toContain(".dbx-btn");
expect(document).toContain("var(--color-background");
});
it("forwards the plugin close-tab shortcut to the host", () => {
const target = { postMessage: vi.fn() } as unknown as Window;
const closeTab = vi.fn();
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
closeTab,
});
expect(
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "shortcut", shortcut: "closeTab" },
} as MessageEvent),
).toBe(true);
expect(closeTab).toHaveBeenCalledOnce();
});
it("opens connect-src only for declared host.network origins", () => {
const allowed = pluginSandboxDocument("<html><head></head><body></body></html>", ["host.events", "host.network:https://api.vendor.com", "host.network:https://metrics.vendor.com:8443", "host.network:http://insecure.vendor.com", "host.network:https://evil.vendor.com/path"]);
expect(allowed).toContain("connect-src https://api.vendor.com https://metrics.vendor.com:8443;");
@@ -28,6 +28,7 @@ export interface PluginHostBridgeApi {
readAsset(pluginId: string, path: string): Promise<PluginUiAssetPayload>;
openWorkbench?(pluginId: string, contributionId: string, context?: PluginWorkbenchContext): Promise<void> | void;
openFilesystem?(pluginId: string, providerId: string, context?: PluginWorkbenchContext): Promise<void> | void;
closeTab?(): Promise<void> | void;
}
interface PluginRequestMessage {
@@ -68,6 +69,10 @@ export class PluginHostBridge {
this.sendInit();
return true;
}
if (event.data.type === "shortcut" && event.data.shortcut === "closeTab") {
void this.api.closeTab?.();
return true;
}
if (event.data.type !== "request" || !validRequestMessage(event.data)) return false;
void this.handleRequest(event.data, target);
return true;
@@ -397,6 +402,13 @@ function pluginSdkSource(): string {
dispatchEvent(new CustomEvent('dbx-plugin-binary', { detail: payload }));
}
});
addEventListener('keydown', (event) => {
const key = typeof event.key === 'string' ? event.key.toLowerCase() : '';
if (key !== 'w' || (!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
parent.postMessage({ source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'shortcut', shortcut: 'closeTab' }, '*');
}, true);
parent.postMessage({ source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'ready' }, '*');
})();`;
}
@@ -7,6 +7,7 @@ import type { ContentAreaSurfaceEmits } from "@/components/layout/querySurfaces"
* four forwarding layers cannot drift apart when the contract changes.
*/
export const contentSurfaceEventNames = [
"closeTab",
"update:activeOutputView",
"fixWithAi",
"sendSelectionToAi",