mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(plugins): refresh workbench tabs after install and rollback
This commit is contained in:
@@ -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();
|
||||
|
||||
+88
-3
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user