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;