diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue
index 87245ca2a2..e1b5a5046e 100644
--- a/apps/desktop/src/App.vue
+++ b/apps/desktop/src/App.vue
@@ -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"
>
-
+
();
// 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 {
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();
diff --git a/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts
index f9aa9119fb..7932f35642 100644
--- a/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts
+++ b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts
@@ -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;
runBatchUninstall: () => Promise;
installMarketplaceListing: (listing: MarketplacePluginListing) => Promise;
- installPlugin: (source: string) => Promise;
+ installPlugin: (source: string | File) => Promise;
installPluginFromUrl: () => Promise;
uninstallSelectedPlugin: () => Promise;
rollbackSelectedPlugin: () => Promise;
@@ -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(/]*@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();
+ 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"));
diff --git a/apps/desktop/src/composables/__tests__/useTauriEvents.spec.ts b/apps/desktop/src/composables/__tests__/useTauriEvents.spec.ts
index e569cca8b0..ab7000efcd 100644
--- a/apps/desktop/src/composables/__tests__/useTauriEvents.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useTauriEvents.spec.ts
@@ -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();
+ });
});
diff --git a/apps/desktop/src/composables/useTauriEvents.ts b/apps/desktop/src/composables/useTauriEvents.ts
index 55e5b83d61..4b35918959 100644
--- a/apps/desktop/src/composables/useTauriEvents.ts
+++ b/apps/desktop/src/composables/useTauriEvents.ts
@@ -11,6 +11,7 @@ export function useTauriEvents(deps: {
openAiConfigDeepLink: (url: string) => Promise;
openPluginInstallDeepLink: (url: string) => Promise;
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(() => {});
}
diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs
index c20f290025..5e4d0b1448 100644
--- a/src-tauri/src/commands/plugins.rs
+++ b/src-tauri/src/commands/plugins.rs
@@ -116,6 +116,7 @@ pub async fn fetch_plugin_marketplace_catalogs(
#[tauri::command]
pub async fn install_marketplace_plugin(
state: State<'_, Arc>,
+ app: AppHandle,
request: PluginMarketplaceInstallRequest,
) -> Result {
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)
#[tauri::command]
pub async fn install_plugin_package(
+ app: AppHandle,
state: State<'_, Arc>,
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>,
plugin_id: String,
) -> Result {
@@ -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, plugin: &InstalledPlugin) {
state.remove_plugin_connection_pools(&plugin.manifest.id).await;
stop_external_driver_pools(state, plugin).await;