feat(plugins): improve connection lifecycle and session tabs
This commit is contained in:
1 parent
6be8d5544e
commit
1321dc9b9d
25 files changed
+849
-44
No files matched your search
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, ref, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readPluginUiEntry: vi.fn(),
|
||||
readPluginUiAsset: vi.fn(),
|
||||
subscribePluginEvents: vi.fn(),
|
||||
repushPluginConnection: vi.fn(),
|
||||
reopenPluginConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
invokePlugin: vi.fn(),
|
||||
notifyPlugin: vi.fn(),
|
||||
sendPluginBinary: vi.fn(),
|
||||
readPluginUiEntry: mocks.readPluginUiEntry,
|
||||
readPluginUiAsset: mocks.readPluginUiAsset,
|
||||
subscribePluginEvents: mocks.subscribePluginEvents,
|
||||
}));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: vi.fn() }));
|
||||
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: ref(false), themeRevision: ref(0) }) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { uiFontFamily: "", fontFamily: "", fontSize: 14 } }) }));
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
repushPluginConnection: mocks.repushPluginConnection,
|
||||
reopenPluginConnection: mocks.reopenPluginConnection,
|
||||
}),
|
||||
}));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ locale: ref("en"), t: (key: string) => key }) }));
|
||||
|
||||
import PluginWorkbenchHost from "./PluginWorkbenchHost.vue";
|
||||
|
||||
const plugin: InstalledPlugin = {
|
||||
manifest: { id: "sample", name: "Sample", version: "1.0.0", permissions: [], drivers: [], contributions: [] },
|
||||
compatibility: { compatible: true },
|
||||
};
|
||||
const contribution: PluginWorkbenchContribution = { type: "workbench", id: "sample.main", label: "Sample" };
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function flushWorkbenchLoad() {
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("PluginWorkbenchHost initialization", () => {
|
||||
let app: App<Element> | undefined;
|
||||
let root: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => window.setTimeout(() => callback(0), 0));
|
||||
vi.stubGlobal("getComputedStyle", () => Object.assign([], { getPropertyValue: () => "" }));
|
||||
mocks.readPluginUiEntry.mockResolvedValue({ dataBase64: btoa("<!doctype html><html><body></body></html>"), contentType: "text/html" });
|
||||
mocks.subscribePluginEvents.mockResolvedValue(vi.fn());
|
||||
mocks.repushPluginConnection.mockReset().mockResolvedValue(undefined);
|
||||
mocks.reopenPluginConnection.mockResolvedValue(undefined);
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function mountHost() {
|
||||
app = createApp(PluginWorkbenchHost, { plugin, contribution, context: { connectionId: "connection" } });
|
||||
app.mount(root);
|
||||
await flushWorkbenchLoad();
|
||||
const frame = root.querySelector("iframe");
|
||||
expect(frame).toBeInstanceOf(HTMLIFrameElement);
|
||||
const target = frame!.contentWindow!;
|
||||
const postMessage = vi.spyOn(target, "postMessage").mockImplementation(() => {});
|
||||
const ready = () => window.dispatchEvent(new MessageEvent("message", { source: target, data: { source: "dbx-plugin", version: 1, type: "ready" } }));
|
||||
ready();
|
||||
await vi.waitFor(() => expect(mocks.repushPluginConnection).toHaveBeenCalled());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
mocks.repushPluginConnection.mockClear();
|
||||
postMessage.mockClear();
|
||||
return { frame: frame!, postMessage, ready };
|
||||
}
|
||||
|
||||
it("runs reinit before one init when load precedes ready", async () => {
|
||||
const firstReinit = deferred();
|
||||
const { frame, postMessage, ready } = await mountHost();
|
||||
mocks.repushPluginConnection.mockReturnValue(firstReinit.promise);
|
||||
|
||||
frame.dispatchEvent(new Event("load"));
|
||||
ready();
|
||||
|
||||
expect(mocks.repushPluginConnection).toHaveBeenCalledTimes(1);
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
firstReinit.resolve();
|
||||
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(1));
|
||||
expect(postMessage.mock.calls[0]?.[0]).toMatchObject({ type: "init" });
|
||||
|
||||
const secondReinit = deferred();
|
||||
mocks.repushPluginConnection.mockReturnValue(secondReinit.promise);
|
||||
frame.dispatchEvent(new Event("load"));
|
||||
ready();
|
||||
|
||||
expect(mocks.repushPluginConnection).toHaveBeenCalledTimes(2);
|
||||
expect(postMessage).toHaveBeenCalledTimes(1);
|
||||
secondReinit.resolve();
|
||||
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("runs reinit before one init when ready precedes load", async () => {
|
||||
const reinit = deferred();
|
||||
const { frame, postMessage, ready } = await mountHost();
|
||||
mocks.repushPluginConnection.mockReturnValue(reinit.promise);
|
||||
|
||||
ready();
|
||||
frame.dispatchEvent(new Event("load"));
|
||||
|
||||
expect(mocks.repushPluginConnection).toHaveBeenCalledTimes(1);
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
reinit.resolve();
|
||||
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(1));
|
||||
expect(postMessage.mock.calls[0]?.[0]).toMatchObject({ type: "init" });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import { PluginHostBridge, pluginSandboxDocument, type PluginBridgeTheme, type P
|
||||
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -21,13 +23,14 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
ready: [];
|
||||
error: [message: string];
|
||||
openWorkbench: [pluginId: string, contributionId: string, context?: PluginWorkbenchContext];
|
||||
openWorkbench: [pluginId: string, contributionId: string, context?: PluginWorkbenchContext, options?: { forceNew?: boolean }];
|
||||
openFilesystem: [pluginId: string, providerId: string, context?: PluginWorkbenchContext];
|
||||
closeTab: [];
|
||||
}>();
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const { isDark, themeRevision } = useTheme();
|
||||
const settingsStore = useSettingsStore();
|
||||
const iframe = ref<HTMLIFrameElement>();
|
||||
const source = ref("");
|
||||
const loading = ref(true);
|
||||
@@ -70,8 +73,9 @@ function createBridge() {
|
||||
notify: api.notifyPlugin,
|
||||
sendBinary: api.sendPluginBinary,
|
||||
readAsset: api.readPluginUiAsset,
|
||||
openWorkbench: async (pluginId, contributionId, context) => emit("openWorkbench", pluginId, contributionId, context),
|
||||
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),
|
||||
closeTab: () => emit("closeTab"),
|
||||
saveFile: (_pluginId, request, data) => savePluginFile(request, data),
|
||||
copyText: (_pluginId, text) => copyToClipboard(text),
|
||||
@@ -79,6 +83,16 @@ function createBridge() {
|
||||
appLocale.value,
|
||||
currentBridgeTheme(),
|
||||
);
|
||||
// An iframe reload (F5 / webview restart) drops the plugin sidecar's
|
||||
// in-memory connection registry while the host still holds the connection
|
||||
// open. Re-push the connection config through the same path as a sidebar
|
||||
// open before the plugin receives its fresh init, so it can reconnect
|
||||
// without the user reopening the connection from the sidebar.
|
||||
bridge.onReinit = async () => {
|
||||
const connectionId = props.context?.connectionId;
|
||||
if (!connectionId) return;
|
||||
await useConnectionStore().repushPluginConnection(connectionId);
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep a plugin-supplied name from smuggling path separators or traversal into the save dialog. */
|
||||
@@ -161,6 +175,7 @@ async function inlineLocalUiAssets(html: string, pluginId: string): Promise<stri
|
||||
|
||||
async function loadWorkbench() {
|
||||
const generation = ++loadGeneration;
|
||||
bridge?.dispose();
|
||||
bridge = undefined;
|
||||
loading.value = true;
|
||||
frameReady.value = false;
|
||||
@@ -234,10 +249,18 @@ watch(appLocale, (locale) => bridge?.updateLocale(locale));
|
||||
// path — dark/light, palette switch, custom colors — re-pushes the resolved
|
||||
// tokens; watching isDark/custom colors alone misses palette-only switches.
|
||||
watch(themeRevision, () => bridge?.updateTheme(currentBridgeTheme()));
|
||||
// Font settings are applied outside applyTheme() (see App.vue applyUiFontFamily)
|
||||
// and therefore never bump themeRevision; watch them explicitly so font token
|
||||
// changes reach live plugin bridges without waiting for the next theme switch.
|
||||
watch(
|
||||
() => [settingsStore.editorSettings.uiFontFamily, settingsStore.editorSettings.fontFamily, settingsStore.editorSettings.fontSize],
|
||||
() => bridge?.updateTheme(currentBridgeTheme()),
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
loadGeneration += 1;
|
||||
bridge?.dispose();
|
||||
bridge = undefined;
|
||||
window.removeEventListener("message", onMessage);
|
||||
unsubscribeEvents?.();
|
||||
|
||||
@@ -30,6 +30,12 @@ const error = ref("");
|
||||
const deferred = ref(false);
|
||||
let loadGeneration = 0;
|
||||
const resolvedConnectionId = computed(() => props.connectionId || (typeof props.context?.connectionId === "string" ? props.context.connectionId : undefined));
|
||||
// The host's re-push-on-reinit and the plugin itself both key off
|
||||
// context.connectionId; filesystem tabs carry it only as a tab-level prop.
|
||||
const hostContext = computed(() => {
|
||||
if (!resolvedConnectionId.value || props.context?.connectionId) return props.context;
|
||||
return { ...props.context, connectionId: resolvedConnectionId.value };
|
||||
});
|
||||
const entry = computed(() => createFrontendPluginRegistry(plugins.value, appLocale.value).findWorkbench(props.pluginId, props.contributionId));
|
||||
|
||||
async function load() {
|
||||
@@ -54,7 +60,18 @@ async function refresh() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (resolvedConnectionId.value) await connectionStore.ensureConnected(resolvedConnectionId.value);
|
||||
const connectionId = resolvedConnectionId.value;
|
||||
if (connectionId) {
|
||||
if (connectionStore.connectedIds.has(connectionId)) {
|
||||
// The connection is open but the plugin sidecar's registry may have
|
||||
// been wiped (iframe reload, tab restore): ensureConnected's health
|
||||
// fast-path only probes the sidecar process and would not heal that.
|
||||
// Force a re-push instead; it no-ops when a push just happened.
|
||||
await connectionStore.repushPluginConnection(connectionId);
|
||||
} else {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
}
|
||||
}
|
||||
await load();
|
||||
} catch (cause) {
|
||||
loading.value = false;
|
||||
@@ -73,9 +90,14 @@ function start() {
|
||||
void load();
|
||||
}
|
||||
|
||||
function openWorkbench(pluginId: string, contributionId: string, context?: PluginWorkbenchContext) {
|
||||
function openWorkbench(pluginId: string, contributionId: string, context?: PluginWorkbenchContext, options?: { forceNew?: boolean }) {
|
||||
const target = createFrontendPluginRegistry(plugins.value, appLocale.value).findWorkbench(pluginId, contributionId);
|
||||
queryStore.openPluginWorkbench(pluginId, contributionId, { title: target?.contribution.label || contributionId, context });
|
||||
// Session tabs are per-connection: title them after the connection (like the
|
||||
// sidebar-opened tab) so parallel sessions read "server", "server (2)", …
|
||||
// — the numbering itself is applied in queryStore.openPluginWorkbench.
|
||||
const contextConnectionId = typeof context?.connectionId === "string" ? context.connectionId : "";
|
||||
const connectionName = contextConnectionId ? connectionStore.getConfig(contextConnectionId)?.name : undefined;
|
||||
queryStore.openPluginWorkbench(pluginId, contributionId, { title: connectionName || target?.contribution.label || contributionId, context, forceNew: options?.forceNew === true });
|
||||
}
|
||||
|
||||
function openFilesystem(pluginId: string, providerId: string, context?: PluginWorkbenchContext) {
|
||||
@@ -105,6 +127,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" @close-tab="emit('closeTab')" />
|
||||
<PluginWorkbenchHost v-else class="min-h-0 flex-1" :plugin="entry.plugin" :contribution="entry.contribution" :context="hostContext" @open-workbench="openWorkbench" @open-filesystem="openFilesystem" @close-tab="emit('closeTab')" />
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user