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

This commit is contained in:
U1
2026-09-18 21:31:38 +08:00
committed by GitHub
parent f033e50ef5
commit 6be8d5544e
6 changed files with 159 additions and 5 deletions
+13 -1
View File
@@ -1010,6 +1010,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
closeActiveSurface,
openAiConfigDeepLink,
openPluginInstallDeepLink,
refreshPluginWorkbenches,
});
const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose });
useVisibilityChange();
@@ -3286,6 +3287,17 @@ function refreshActivePluginWorkbench(): boolean {
return true;
}
// Installs/rollbacks replace the plugin runtime in place; already-open
// workbench tabs keep rendering the previous UI bundle until they reload.
// refresh() re-fetches listPlugins, and PluginWorkbenchHost's version watch
// rebuilds the sandbox iframe from the new package.
function refreshPluginWorkbenches(pluginId: string): void {
for (const tab of mountedPluginWorkbenchTabs.value) {
if (tab.pluginWorkbench?.pluginId !== pluginId) continue;
pluginWorkbenchTabRefs.get(tab.id)?.refresh();
}
}
async function closeActiveSurface() {
if (showSettingsPage.value) {
closeSettingsPage();
@@ -3839,7 +3851,7 @@ onUnmounted(() => {
@detach-tab="detachTab"
>
<DriverStorePage v-if="driverStoreTabOpen" v-show="driverStoreActive" v-model:active-tab="driverStoreActiveTab" class="flex-1 min-h-0" :update-notifications-enabled="updateNotificationsEnabled" :focus-target="driverStoreFocus" @update-count-change="updateAgentDriverUpdateCount" />
<PluginCenterPage v-if="pluginCenterTabOpen" v-show="pluginCenterActive" class="flex-1 min-h-0" :focus-target="pluginCenterFocus" :install-url-request="pluginCenterInstallRequest" @new-connection="openPluginConnectionDialog" />
<PluginCenterPage v-if="pluginCenterTabOpen" v-show="pluginCenterActive" class="flex-1 min-h-0" :focus-target="pluginCenterFocus" :install-url-request="pluginCenterInstallRequest" @new-connection="openPluginConnectionDialog" @plugin-runtime-replaced="refreshPluginWorkbenches" />
<EditorSettingsPage
v-if="settingsPageTabOpen"
v-show="settingsStore.settingsPageActive"
@@ -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"));
@@ -34,6 +34,7 @@ describe("useTauriEvents", () => {
openDbFilePath: vi.fn(),
openConnectionDeepLink: vi.fn(),
closeActiveSurface,
refreshPluginWorkbenches: vi.fn(),
});
events.setupTauriListeners();
@@ -44,4 +45,23 @@ describe("useTauriEvents", () => {
events.cleanupTauriListeners();
expect(unlisten).toHaveBeenCalled();
});
it("forwards replaced plugin runtimes to the workbench refresh hook", async () => {
const refreshPluginWorkbenches = vi.fn();
const events = useTauriEvents({
openTableTarget: vi.fn(),
openSqlFilePath: vi.fn(),
openDbFilePath: vi.fn(),
openConnectionDeepLink: vi.fn(),
closeActiveSurface: vi.fn(),
refreshPluginWorkbenches,
});
events.setupTauriListeners();
await vi.waitFor(() => expect(listeners.has("plugin-runtime-replaced")).toBe(true));
listeners.get("plugin-runtime-replaced")!({ payload: { pluginId: "io.example.plugin", version: "0.2.0" } });
expect(refreshPluginWorkbenches).toHaveBeenCalledExactlyOnceWith("io.example.plugin");
events.cleanupTauriListeners();
});
});
@@ -11,6 +11,7 @@ export function useTauriEvents(deps: {
openAiConfigDeepLink: (url: string) => Promise<void>;
openPluginInstallDeepLink: (url: string) => Promise<void>;
closeActiveSurface: () => void;
refreshPluginWorkbenches: (pluginId: string) => void;
}) {
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
@@ -168,6 +169,13 @@ export function useTauriEvents(deps: {
listen("dbx-close-active-tab", () => {
deps.closeActiveSurface();
}).then((unlisten) => unlistenHandles.push(unlisten));
// A plugin was installed/rolled back from its already-replaced runtime:
// open workbench tabs still render the previous UI bundle until they
// reload, so hand them the new identity to pick up.
listen<{ pluginId: string; version: string }>("plugin-runtime-replaced", (event) => {
if (event.payload?.pluginId) deps.refreshPluginWorkbenches(event.payload.pluginId);
}).then((unlisten) => unlistenHandles.push(unlisten));
})
.catch(() => {});
}
+22 -1
View File
@@ -116,6 +116,7 @@ pub async fn fetch_plugin_marketplace_catalogs(
#[tauri::command]
pub async fn install_marketplace_plugin(
state: State<'_, Arc<AppState>>,
app: AppHandle,
request: PluginMarketplaceInstallRequest,
) -> Result<PluginInstallResponse, String> {
let marketplace =
@@ -123,6 +124,7 @@ pub async fn install_marketplace_plugin(
.with_lifecycle(state.plugins.lifecycle());
let result = marketplace.install(request).await?;
stop_replaced_plugin_runtime(&state, &result.plugin).await;
emit_plugin_runtime_replaced(&app, &result.plugin);
Ok(result.response())
}
@@ -183,6 +185,7 @@ pub fn install_plugin_event_bridge(app: &tauri::AppHandle, state: Arc<AppState>)
#[tauri::command]
pub async fn install_plugin_package(
app: AppHandle,
state: State<'_, Arc<AppState>>,
path: String,
allow_unsigned: bool,
@@ -198,6 +201,7 @@ pub async fn install_plugin_package(
.await
.map_err(|error| error.to_string())??;
stop_replaced_plugin_runtime(&state, &result.plugin).await;
emit_plugin_runtime_replaced(&app, &result.plugin);
Ok(result.response())
}
@@ -212,9 +216,10 @@ pub async fn install_plugin_package_from_url(
PluginMarketplace::new(state.plugins.root_dir().to_path_buf(), state.plugins.app_version().to_string())?
.with_lifecycle(state.plugins.lifecycle());
let policy = if allow_unsigned { PluginInstallPolicy::LocalDevelopment } else { PluginInstallPolicy::LocalSigned };
let progress_app = app.clone();
let result = marketplace
.install_url_package(&url, policy, move |downloaded, total| {
let _ = app.emit(
let _ = progress_app.emit(
"plugin-url-download-progress",
serde_json::json!({
"downloaded": downloaded,
@@ -224,11 +229,13 @@ pub async fn install_plugin_package_from_url(
})
.await?;
stop_replaced_plugin_runtime(&state, &result.plugin).await;
emit_plugin_runtime_replaced(&app, &result.plugin);
Ok(result.response())
}
#[tauri::command]
pub async fn rollback_plugin(
app: AppHandle,
state: State<'_, Arc<AppState>>,
plugin_id: String,
) -> Result<PluginRollbackResponse, String> {
@@ -241,6 +248,7 @@ pub async fn rollback_plugin(
.await
.map_err(|error| error.to_string())??;
stop_replaced_plugin_runtime(&state, &result.plugin).await;
emit_plugin_runtime_replaced(&app, &result.plugin);
Ok(result.response())
}
@@ -500,6 +508,19 @@ fn asset_payload(asset: PluginUiAsset) -> PluginUiAssetPayload {
}
}
/// Notifies the webview that a plugin's runtime was replaced by an
/// install/rollback, so already-open workbench tabs can reload the new UI
/// instead of showing the stale version until manually reopened.
fn emit_plugin_runtime_replaced(app: &AppHandle, plugin: &InstalledPlugin) {
let _ = app.emit(
"plugin-runtime-replaced",
serde_json::json!({
"pluginId": plugin.manifest.id,
"version": plugin.manifest.version,
}),
);
}
async fn stop_replaced_plugin_runtime(state: &Arc<AppState>, plugin: &InstalledPlugin) {
state.remove_plugin_connection_pools(&plugin.manifest.id).await;
stop_external_driver_pools(state, plugin).await;