diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 8efa4154b2..2c943fbb1e 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -212,10 +212,16 @@ type DataGridHandle = DataGridColumnLayoutHandle & { type SearchableBrowserHandle = { focusSearch: (target?: Element | null) => boolean; refresh?: () => boolean; + matchesRefreshScope?: (scope: ObjectBrowserRefreshScope) => boolean; insertCommand?: (command: string) => Promise; executeCommand?: (command: string) => Promise; }; +type ObjectBrowserRefreshScope = { + schema?: string; + catalog?: string; +}; + type ElasticsearchJsonResponsePanelHandle = { focusSearch: () => boolean; }; @@ -268,6 +274,7 @@ onMounted(() => { // The watcher below warms the grid for query/data tabs. Keep source-only // tabs out of that path: loading the grid there caused freezes (#8103). window.addEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser); + window.addEventListener("dbx-refresh-object-browser", onRefreshObjectBrowser); window.addEventListener("resize", updateStandaloneResultToolbarDimensions); window.visualViewport?.addEventListener("resize", updateStandaloneResultToolbarDimensions); window.addEventListener("dbx:ui-scale-applied", updateStandaloneResultToolbarDimensions); @@ -767,6 +774,7 @@ onUnmounted(() => { stopRunningElapsedTimer(); standaloneResultToolbarResizeObserver?.disconnect(); window.removeEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser); + window.removeEventListener("dbx-refresh-object-browser", onRefreshObjectBrowser); window.removeEventListener("resize", updateStandaloneResultToolbarDimensions); window.visualViewport?.removeEventListener("resize", updateStandaloneResultToolbarDimensions); window.removeEventListener("dbx:ui-scale-applied", updateStandaloneResultToolbarDimensions); @@ -1032,6 +1040,22 @@ function onRefreshActiveKvBrowser(event: Event) { void nextTick(() => refreshData()); } +function matchesActiveObjectBrowserRefreshScope(detail: { connectionId?: string; database?: string } & ObjectBrowserRefreshScope): boolean { + if (props.activeTab.mode !== "objects" || props.activeTab.connectionId !== detail.connectionId || props.activeTab.database !== detail.database) return false; + const matchesRefreshScope = objectBrowserRef.value?.matchesRefreshScope; + if (matchesRefreshScope) return matchesRefreshScope(detail); + const objectBrowser = props.activeTab.objectBrowser; + return (objectBrowser?.schema || props.activeTab.schema || "") === (detail.schema || "") && (objectBrowser?.catalog || props.activeTab.catalog || "") === (detail.catalog || ""); +} + +function onRefreshObjectBrowser(event: Event) { + const detail = (event as CustomEvent<{ connectionId?: string; database?: string; schema?: string; catalog?: string }>).detail; + if (!detail || !matchesActiveObjectBrowserRefreshScope(detail)) return; + void nextTick(() => { + if (matchesActiveObjectBrowserRefreshScope(detail)) refreshData(); + }); +} + function openPluginResultView(pluginId: string, contributionId: string, label: string) { const result = props.activeTab.result; if (!result) return; diff --git a/apps/desktop/src/components/layout/__tests__/ContentArea.objectBrowserRefresh.spec.ts b/apps/desktop/src/components/layout/__tests__/ContentArea.objectBrowserRefresh.spec.ts new file mode 100644 index 0000000000..fcb103ca0c --- /dev/null +++ b/apps/desktop/src/components/layout/__tests__/ContentArea.objectBrowserRefresh.spec.ts @@ -0,0 +1,136 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { createI18n } from "vue-i18n"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionConfig, QueryTab } from "@/types/database"; + +const mocks = vi.hoisted(() => ({ + refresh: vi.fn(), + schema: "APP" as string | undefined, +})); + +vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/grid/DataGrid.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/objects/ObjectBrowser.vue", () => ({ + __esModule: true, + default: defineComponent({ + props: { catalog: String }, + setup(props, { expose }) { + expose({ + focusSearch: () => false, + refresh: mocks.refresh, + matchesRefreshScope: (scope: { schema?: string; catalog?: string }) => (mocks.schema || "") === (scope.schema || "") && (props.catalog || "") === (scope.catalog || ""), + }); + return () => h("div", { "data-test": "object-browser" }); + }, + }), +})); + +import ContentArea from "../ContentArea.vue"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useQueryStore } from "@/stores/queryStore"; + +const mountedApps: Array<{ app: App; host: HTMLElement }> = []; + +const connection = { + id: "dameng", + name: "Dameng", + db_type: "dameng", + host: "localhost", + port: 5236, + username: "SYSDBA", + password: "", +} as ConnectionConfig; + +beforeEach(() => { + mocks.refresh.mockReset(); + mocks.refresh.mockReturnValue(true); + mocks.schema = "APP"; +}); + +afterEach(() => { + for (const { app, host } of mountedApps.splice(0)) { + app.unmount(); + host.remove(); + } + window.localStorage?.clear(); +}); + +async function mountObjectBrowserTab() { + const pinia = createPinia(); + setActivePinia(pinia); + useConnectionStore().connections = [connection]; + const tab = reactive({ + id: "objects", + title: "app objects", + connectionId: connection.id, + database: "app", + mode: "objects", + sql: "", + isExecuting: false, + }); + useQueryStore().tabs.push(tab); + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp({ + setup: () => () => + h(ContentArea, { + activeTab: tab, + activeConnection: connection, + activeOutputView: "result", + executableSql: "", + formatSqlRequest: null, + compressSqlRequest: null, + selectedSql: "", + cursorPos: 0, + blockDangerousRedisCommands: false, + }), + }); + app.use(pinia); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + mountedApps.push({ app, host }); + await vi.waitFor(() => expect(host.querySelector('[data-test="object-browser"]')).not.toBeNull()); + return { tab }; +} + +function dispatchRefresh(detail: { connectionId?: string; database?: string; schema?: string; catalog?: string }) { + window.dispatchEvent(new CustomEvent("dbx-refresh-object-browser", { detail })); +} + +describe("ContentArea object browser refresh notifications", () => { + it("refreshes an object tab using the schema selected inside ObjectBrowser", async () => { + await mountObjectBrowserTab(); + + dispatchRefresh({ connectionId: connection.id, database: "app", schema: "APP" }); + await nextTick(); + + expect(mocks.refresh).toHaveBeenCalledOnce(); + }); + + it.each([ + { connectionId: "other", database: "app", schema: "APP" }, + { connectionId: connection.id, database: "other", schema: "APP" }, + { connectionId: connection.id, database: "app", schema: "OTHER" }, + { connectionId: connection.id, database: "app", schema: "APP", catalog: "OTHER" }, + ])("ignores a refresh outside the active browser scope: %o", async (detail) => { + await mountObjectBrowserTab(); + + dispatchRefresh(detail); + await nextTick(); + + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + + it("rechecks the ObjectBrowser scope before the deferred refresh", async () => { + await mountObjectBrowserTab(); + + dispatchRefresh({ connectionId: connection.id, database: "app", schema: "APP" }); + mocks.schema = "OTHER"; + await nextTick(); + + expect(mocks.refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 6269f40c38..46e92c5c4c 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -3102,7 +3102,11 @@ function clearObjectSearch() { getSearchInput()?.focus(); } -defineExpose({ focusSearch, refresh }); +function matchesRefreshScope(scope: { schema?: string; catalog?: string }): boolean { + return (selectedSchema.value || "") === (scope.schema || "") && (props.catalog || "") === (scope.catalog || ""); +} + +defineExpose({ focusSearch, refresh, matchesRefreshScope }); onBeforeUnmount(() => { objectBrowserRowsLoadGuard.invalidate(); diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.component.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.component.spec.ts index 25271c445d..a51b406a9e 100644 --- a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.component.spec.ts +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.component.spec.ts @@ -8,13 +8,14 @@ import type { ConnectionConfig, ObjectInfo } from "@/types/database"; const mocks = vi.hoisted(() => ({ listObjects: vi.fn(), + listSchemas: vi.fn(), ensureConnected: vi.fn(), editorInstances: 0, })); vi.mock("@/lib/backend/api", () => ({ listObjects: (...args: unknown[]) => mocks.listObjects(...args), - listSchemas: vi.fn().mockResolvedValue([]), + listSchemas: (...args: unknown[]) => mocks.listSchemas(...args), listObjectStatistics: vi.fn().mockResolvedValue([]), })); vi.mock("@/stores/connectionStore", () => ({ @@ -81,6 +82,7 @@ const mountedApps: Array<{ app: App; host: HTMLElement }> = []; beforeEach(() => { vi.clearAllMocks(); mocks.listObjects.mockResolvedValue([]); + mocks.listSchemas.mockResolvedValue([]); mocks.ensureConnected.mockResolvedValue(undefined); mocks.editorInstances = 0; invalidateObjectBrowserRowsCache({}); @@ -96,14 +98,15 @@ afterEach(() => { async function mountBrowser(overrides: Partial["$props"]> = {}) { const props = reactive({ connection, database: "app", initialEventCreateRequestId: 1 as number | undefined, ...overrides }); + const browser = ref | null>(null); const host = document.createElement("div"); document.body.append(host); - const app = createApp({ setup: () => () => h(ObjectBrowser, props) }); + const app = createApp({ setup: () => () => h(ObjectBrowser, { ...props, ref: browser }) }); mountedApps.push({ app, host }); app.mount(host); await vi.waitFor(() => expect(mocks.listObjects).toHaveBeenCalledOnce()); await nextTick(); - return { host, props }; + return { browser, host, props }; } describe("ObjectBrowser event editor rendering", () => { @@ -184,3 +187,15 @@ describe("ObjectBrowser event editor rendering", () => { expect(host.querySelector("[data-event-editor]")).toBeNull(); }); }); + +describe("ObjectBrowser refresh scope", () => { + it("matches the schema selected automatically by loadSchemas", async () => { + mocks.listSchemas.mockResolvedValue(["APP", "OTHER"]); + const damengConnection = { ...connection, id: "dameng", name: "Dameng", db_type: "dameng" } as ConnectionConfig; + const { browser } = await mountBrowser({ connection: damengConnection, initialEventCreateRequestId: undefined }); + + expect(browser.value?.matchesRefreshScope({ schema: "APP" })).toBe(true); + expect(browser.value?.matchesRefreshScope({ schema: "OTHER" })).toBe(false); + expect(browser.value?.matchesRefreshScope({ schema: "APP", catalog: "OTHER" })).toBe(false); + }); +}); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index dd6533ff78..ed16ff760c 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -2631,6 +2631,16 @@ async function compileDamengView() { if (!executed) return; toast(t("contextMenu.compileObjectSuccess", { name: node.label }), 3000); await connectionStore.refreshObjectListTreeNode(node.connectionId, node.database, node.schema); + window.dispatchEvent( + new CustomEvent("dbx-refresh-object-browser", { + detail: { + connectionId: node.connectionId, + database: node.database, + schema: node.schema, + catalog: node.catalog, + }, + }), + ); } catch (e: any) { compileErrorTitle.value = t("contextMenu.compileObjectFailedTitle"); compileErrorMessage.value = t("contextMenu.compileObjectFailedMessage", { name: node.label, message: e?.message || String(e) }); diff --git a/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts b/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts index d8a5ac025c..ab03dfd83c 100644 --- a/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts @@ -37,7 +37,7 @@ describe("ContentArea object browser refresh wiring", () => { it("exposes object and active table-info reloads as refresh", () => { expect(objectBrowserSource).toMatch(/function refresh\(\): boolean \{\s+void reload\(\{ preserveExistingRows: true \}\);\s+void refreshActiveTableInfo\(\);\s+return true;\s+\}/); - expect(objectBrowserSource).toContain("defineExpose({ focusSearch, refresh });"); + expect(objectBrowserSource).toContain("defineExpose({ focusSearch, refresh, matchesRefreshScope });"); }); it("shows the configured content refresh shortcut on the refresh button", () => {