fix(plugins): refresh workbench tabs after install and rollback

This commit is contained in:
U1 authored and GitHub committed 2026-09-18 21:31:38 +08:00
1 parent f033e50ef5
commit 6be8d5544e
6 files changed
+159 -5

No files matched your search

@@ -35,6 +35,7 @@ const props = defineProps<{
const emit = defineEmits<{
newConnection: [pluginId: string, providerId: string];
pluginRuntimeReplaced: [pluginId: string];
}>();
// lucide no longer ships brand icons; mirror the inline glyph used in AppToolbar.
@@ -192,12 +193,17 @@ function applyFocusTarget(focus: PluginCenterFocus) {
selectProvider(focus.pluginId, provider.contribution.id);
}
function notifyPluginRuntimeReplaced(pluginId: string) {
if (!isTauriRuntime()) emit("pluginRuntimeReplaced", pluginId);
}
async function installListing(listing: MarketplacePluginListing): Promise<PluginInstallResult> {
const result = await api.installMarketplacePlugin({
repositoryId: listing.repository.id,
pluginId: listing.plugin.id,
version: listing.plugin.latestVersion,
});
notifyPluginRuntimeReplaced(result.plugin.manifest.id);
beaconPluginInstall(listing.plugin.id, listing.plugin.latestVersion);
return result;
}
@@ -518,6 +524,7 @@ function reportInstallBeacon(result: PluginInstallResult) {
}
async function finishInstall(result: PluginInstallResult) {
notifyPluginRuntimeReplaced(result.plugin.manifest.id);
reportInstallBeacon(result);
toast(t("pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
clearPluginIconCache();
@@ -664,6 +671,7 @@ async function rollbackSelectedPlugin() {
operating.value = true;
try {
const result = await api.rollbackPlugin(selectedPluginId.value);
notifyPluginRuntimeReplaced(result.plugin.manifest.id);
toast(t("pluginPlatform.rollbackSuccess", { version: result.plugin.manifest.version }));
clearPluginIconCache();
installedPlugins.value = await api.listPlugins();
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App, type ComponentPublicInstance } from "vue";
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database";
import type { MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
@@ -20,13 +21,16 @@ const mocks = vi.hoisted(() => ({
savePluginTrustedKey: vi.fn(),
removePluginTrustedKey: vi.fn(),
toast: vi.fn(),
isTauriRuntime: vi.fn(),
refreshPluginWorkbenches: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => mocks);
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [] }) }));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: mocks.isTauriRuntime }));
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) }));
vi.mock("vue-i18n", async () => {
const { ref } = await import("vue");
return { useI18n: () => ({ locale: ref("en"), t: (key: string, values = {}) => `${key}:${JSON.stringify(values)}` }) };
@@ -78,7 +82,7 @@ type PanelState = {
runBatchInstallUpdate: () => Promise<void>;
runBatchUninstall: () => Promise<void>;
installMarketplaceListing: (listing: MarketplacePluginListing) => Promise<void>;
installPlugin: (source: string) => Promise<void>;
installPlugin: (source: string | File) => Promise<void>;
installPluginFromUrl: () => Promise<void>;
uninstallSelectedPlugin: () => Promise<void>;
rollbackSelectedPlugin: () => Promise<void>;
@@ -165,6 +169,7 @@ async function expectBusyControls() {
beforeEach(async () => {
vi.resetAllMocks();
mocks.isTauriRuntime.mockReturnValue(false);
localStorage.clear();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({}));
vi.stubGlobal("confirm", vi.fn().mockReturnValue(true));
@@ -180,7 +185,7 @@ beforeEach(async () => {
mocks.removePluginTrustedKey.mockResolvedValue([]);
host = document.createElement("div");
document.body.append(host);
app = createApp(PluginContributionsPanel);
app = createApp(PluginContributionsPanel, { onPluginRuntimeReplaced: mocks.refreshPluginWorkbenches });
const instance = app.mount(host) as ComponentPublicInstance & { $: { setupState: PanelState } };
state = instance.$.setupState;
await flushUi();
@@ -322,6 +327,86 @@ function singleApi(single: Single) {
return { marketplace: mocks.installMarketplacePlugin, uninstall: mocks.uninstallPlugin, rollback: mocks.rollbackPlugin, package: mocks.installPluginPackage, url: mocks.installPluginPackageFromUrl }[single];
}
const replacements = ["marketplace", "rollback", "package", "url"] as const;
describe("PluginContributionsPanel workbench refresh", () => {
it("connects the panel event to the App workbench refresh entry", () => {
const source = readFileSync("apps/desktop/src/App.vue", "utf8");
expect(source).toMatch(/<PluginCenterPage\b[^>]*@plugin-runtime-replaced="refreshPluginWorkbenches"/);
});
it.each(replacements)("refreshes once with the returned plugin ID after Web %s succeeds", async (single) => {
singleApi(single).mockResolvedValueOnce({ plugin: installed("replaced", "2.0.0") });
await startSingle(single);
expect(mocks.refreshPluginWorkbenches).toHaveBeenCalledExactlyOnceWith("replaced");
});
it("refreshes after uploading a Web package File", async () => {
const file = new File(["package"], "plugin.dbxp");
await state.installPlugin(file);
expect(mocks.installPluginPackage).toHaveBeenCalledExactlyOnceWith(file, false);
expect(mocks.refreshPluginWorkbenches).toHaveBeenCalledExactlyOnceWith("a");
});
it.each(replacements)("does not refresh after Web %s fails", async (single) => {
singleApi(single).mockRejectedValueOnce(new Error("replacement denied"));
await startSingle(single);
expect(mocks.refreshPluginWorkbenches).not.toHaveBeenCalled();
});
it.each(replacements)("still refreshes after Web %s succeeds but the panel list reload fails", async (single) => {
mocks.listPlugins.mockRejectedValue(new Error("list unavailable"));
await startSingle(single);
expect(mocks.refreshPluginWorkbenches).toHaveBeenCalledExactlyOnceWith("a");
});
it.each(replacements)("leaves Tauri %s refresh to the native runtime event", async (single) => {
mocks.isTauriRuntime.mockReturnValue(true);
await startSingle(single);
expect(singleApi(single)).toHaveBeenCalledOnce();
expect(mocks.refreshPluginWorkbenches).not.toHaveBeenCalled();
});
it("refreshes each successful Web batch replacement without waiting for the batch to finish", async () => {
const pending = deferred<unknown>();
state.selectAllUpdatable();
mocks.installMarketplacePlugin.mockImplementation(async ({ pluginId }: { pluginId: string }) => {
if (pluginId === "b") throw new Error("replacement denied");
if (pluginId === "c") return pending.promise;
return { plugin: installed(pluginId, "3.0.0") };
});
const running = state.runBatchInstallUpdate();
try {
await flushUi();
expect(mocks.refreshPluginWorkbenches.mock.calls).toEqual([["a"]]);
} finally {
pending.resolve({ plugin: installed("c", "3.0.0") });
await running;
}
expect(mocks.refreshPluginWorkbenches.mock.calls).toEqual([["a"], ["c"]]);
expect(state.error).toContain("replacement denied");
});
it("leaves Tauri batch refresh to the native runtime events", async () => {
mocks.isTauriRuntime.mockReturnValue(true);
state.selectAllUpdatable();
await state.runBatchInstallUpdate();
expect(mocks.installMarketplacePlugin).toHaveBeenCalledTimes(3);
expect(mocks.refreshPluginWorkbenches).not.toHaveBeenCalled();
});
it("does not refresh for rejected confirmation, invalid URLs, or uninstall", async () => {
vi.mocked(window.confirm).mockReturnValueOnce(false);
await state.rollbackSelectedPlugin();
state.installUrl = "file:///plugin.dbxp";
await state.installPluginFromUrl();
expect(mutationCount()).toBe(0);
await state.uninstallSelectedPlugin();
expect(mocks.uninstallPlugin).toHaveBeenCalledOnce();
expect(mocks.refreshPluginWorkbenches).not.toHaveBeenCalled();
});
});
describe("PluginContributionsPanel mutation exclusion", () => {
it.each(singles.flatMap((single) => [false, true].map((reject) => ({ single, reject }))))("releases single $single exclusion after rejection=$reject", async ({ single, reject }) => {
if (reject) singleApi(single).mockRejectedValueOnce(new Error("single denied"));