From dfb9971849f40fe6cfd823496f4e82892ede822c Mon Sep 17 00:00:00 2001 From: Guoyu Su Date: Fri, 7 Aug 2026 03:24:25 +0800 Subject: [PATCH] feat(sidebar): expose visible object filters in the connection tree * feat(sidebar): expose visible object filter * fix(sidebar): refine visible filter status * refactor(sidebar): move visible filter status to details * fix(sidebar): clarify visible filter action --- .../sidebar/SidebarTreeRuntimeHost.vue | 6 + .../src/components/sidebar/TreeItem.vue | 24 +++ .../sidebar/VisibleDatabasesDialog.vue | 1 + .../TreeItem.loadMoreActivation.spec.ts | 1 + .../TreeItem.visibleFilterDetail.spec.ts | 180 ++++++++++++++++++ .../useSidebarConnectionMutationRuntime.ts | 3 +- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/it.ts | 4 + apps/desktop/src/i18n/locales/ja.ts | 4 + apps/desktop/src/i18n/locales/ko.ts | 4 + apps/desktop/src/i18n/locales/pt-BR.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + .../sidebar/sidebarTreeRuntime.spec.ts | 12 ++ .../connection/connectionVisibleDatabases.ts | 6 +- .../src/lib/sidebar/sidebarTreeRuntime.ts | 5 + .../lib/sidebar/sidebarVisibleFilterMenu.ts | 13 ++ .../sidebar/sidebarVisibleFilterSummary.ts | 42 ++++ ...ectionStore.duckdbVisibleDatabases.spec.ts | 121 ++++++++++++ ...ectionStore.visibleFilterLifecycle.spec.ts | 126 ++++++++++++ apps/desktop/src/stores/connectionStore.ts | 70 ++++++- .../connectionVisibleDatabases.test.ts | 5 + .../sidebarRuntimeDecomposition.test.ts | 12 ++ .../sidebarVisibleFilterMenu.test.ts | 16 +- .../sidebarVisibleFilterSummary.test.ts | 127 ++++++++++++ 26 files changed, 793 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/components/sidebar/__tests__/TreeItem.visibleFilterDetail.spec.ts create mode 100644 apps/desktop/src/lib/sidebar/sidebarVisibleFilterSummary.ts create mode 100644 apps/desktop/src/stores/__tests__/connectionStore.duckdbVisibleDatabases.spec.ts create mode 100644 apps/desktop/src/stores/__tests__/connectionStore.visibleFilterLifecycle.spec.ts create mode 100644 packages/app-tests/sidebarVisibleFilterSummary.test.ts diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 6aad0dc4cd..8e5171bbbe 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -4828,6 +4828,11 @@ function handleRowKeydown(node: TreeNode, event: KeyboardEvent) { onKeydown(event); } +function openPrimaryVisibleFilter(node: TreeNode) { + activateRuntimeNode(node); + openVisibleDatabasesDialog(); +} + function openDataInNewTab(node: TreeNode) { activateRuntimeNode(node); openDataInNewTabImmediately(node); @@ -4848,6 +4853,7 @@ defineExpose({ handleRowClick, handleRowDoubleClick, handleRowKeydown, + openPrimaryVisibleFilter, openDataInNewTab, requestPaste, toggleNode, diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 608e48398f..b6d743bb41 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -62,6 +62,7 @@ import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelToo import { treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection"; import { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; +import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu"; import { isWindows } from "@/lib/backend/platform"; import { flattenTree } from "@/composables/useFlatTree"; import { productionContextForDatabase } from "@/lib/database/productionSafety"; @@ -364,6 +365,8 @@ type DetailTooltipRow = { multiline?: boolean; /** When set, renders each value on its own line (e.g. one host per line) */ values?: string[]; + action?: () => void; + actionLabel?: string; }; function cleanTooltipValue(value: string | number | null | undefined): string { @@ -420,6 +423,16 @@ const detailTooltip = computed(() => { .map((h) => h.trim()) .filter(Boolean) : []; + const visibleFilterSummary = connectionCanConfigureSidebarVisibleDatabases(config.db_type) ? connectionStore.getSidebarVisibleFilterSummary(node.connectionId) : null; + const visibleFilterRow: DetailTooltipRow | null = + visibleFilterSummary?.selected != null && visibleFilterSummary.total != null + ? { + label: t(visibleFilterSummary.mode === "schema" ? "visibleSchemas.detailLabel" : "visibleDatabases.detailLabel"), + value: `${visibleFilterSummary.selected}/${visibleFilterSummary.total}`, + action: () => treeRuntime.openPrimaryVisibleFilter(node), + actionLabel: t(visibleFilterSummary.mode === "schema" ? "visibleSchemas.detailActionLabel" : "visibleDatabases.detailActionLabel", { connection: config.name }), + } + : null; const rows: DetailTooltipRow[] = [ { label: t("connection.name"), value: cleanTooltipValue(config.name) }, { label: "URL", value: connectionTooltipUrl(config), multiline: true }, @@ -429,6 +442,7 @@ const detailTooltip = computed(() => { { label: t("connection.user"), value: cleanTooltipValue(config.username) }, { label: t("connection.type"), value: config.driver_label || config.driver_profile || config.db_type }, { label: t("connection.databaseInfo.productVersion"), value: cleanTooltipValue(config.database_info?.productVersion) }, + ...(visibleFilterRow ? [visibleFilterRow] : []), { label: t("connection.note"), value: cleanTooltipValue(config.note), multiline: true }, ].filter((row) => row.value); return { rows }; @@ -1248,6 +1262,16 @@ function onKeydown(event: KeyboardEvent) { {{ v }} + {{ row.value }} diff --git a/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue b/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue index 4f1cf403a8..79f82aeb64 100644 --- a/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue +++ b/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue @@ -84,6 +84,7 @@ async function loadDatabases() { searchText.value = ""; try { const names = await loadObjectNames(); + connectionStore.recordPrimaryVisibleObjectNames(props.connectionId, names); objectNames.value = names; showSystemDatabases.value = false; const configured = isSchemaFilterMode.value ? connection.value?.visible_schemas?.[databaseKey.value] : connection.value?.visible_databases; diff --git a/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts b/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts index 2a6aac05ac..65beaf6909 100644 --- a/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts @@ -61,6 +61,7 @@ function runtimeHost(): SidebarTreeRuntimeHost { handleRowClick: vi.fn(), handleRowDoubleClick: vi.fn(), handleRowKeydown: vi.fn(), + openPrimaryVisibleFilter: vi.fn(), openDataInNewTab: vi.fn(), requestPaste: vi.fn(() => false), toggleNode: vi.fn(), diff --git a/apps/desktop/src/components/sidebar/__tests__/TreeItem.visibleFilterDetail.spec.ts b/apps/desktop/src/components/sidebar/__tests__/TreeItem.visibleFilterDetail.spec.ts new file mode 100644 index 0000000000..9e0548386c --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/TreeItem.visibleFilterDetail.spec.ts @@ -0,0 +1,180 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import TreeItem from "@/components/sidebar/TreeItem.vue"; +import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime"; +import type { SidebarVisibleFilterSummary } from "@/lib/sidebar/sidebarVisibleFilterSummary"; +import type { ConnectionConfig, TreeNode } from "@/types/database"; + +const state: { + config: ConnectionConfig; + summary: SidebarVisibleFilterSummary; +} = { + config: mysqlConnection(), + summary: { mode: "database", isActive: true, selected: 2, total: 3 }, +}; + +function mysqlConnection(): ConnectionConfig { + return { + id: "connection-1", + name: "Filtered connection", + db_type: "mysql", + host: "127.0.0.1", + port: 3306, + username: "root", + password: "", + database: "filter_alpha", + } as ConnectionConfig; +} + +const connectionStore = { + activeConnectionId: "connection-1", + connectedIds: new Set(["connection-1"]), + connectingIds: new Set(), + connectionErrors: {}, + connectionMultiSelectActive: false, + connections: [], + getConfig: () => state.config, + getSidebarVisibleFilterSummary: () => state.summary, + clearConnectionError: vi.fn(), + isDefaultDatabase: () => false, + isPinnedTreeNodeReorderTarget: () => false, + isTreeNodeChildrenLoaded: () => false, + isTreeNodePinned: () => false, + selectedTreeNodeId: null as string | null, + selectedTreeNodeIds: [] as string[], + selectedTreeNodeIdsSet: new Set(), + sidebarTableSearchQueries: {}, + tableNameFilterForScope: () => undefined, + treeNodes: [], + treeSelectionAnchorId: null as string | null, +}; + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => connectionStore, +})); + +vi.mock("@/stores/queryStore", () => ({ + useQueryStore: () => ({ openDatabaseKeys: new Set() }), +})); + +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { + shortcuts: { openDataInNewTab: "" }, + sidebarActivation: "double", + sidebarAllowHorizontalScroll: false, + sidebarHiddenTablePrefixes: [], + sidebarObjectInfoMode: "none", + }, + }), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +const mountedApps: App[] = []; + +function runtimeHost(): SidebarTreeRuntimeHost { + return { + buildContextMenu: vi.fn(() => []), + handleRowClick: vi.fn(), + handleRowDoubleClick: vi.fn(), + handleRowKeydown: vi.fn(), + openPrimaryVisibleFilter: vi.fn(), + openDataInNewTab: vi.fn(), + requestPaste: vi.fn(() => false), + toggleNode: vi.fn(), + }; +} + +async function openConnectionTooltip() { + vi.useFakeTimers(); + const container = document.createElement("div"); + document.body.append(container); + const node: TreeNode = { + id: "connection-1", + label: "Filtered connection", + type: "connection", + connectionId: "connection-1", + isExpanded: true, + children: [], + }; + const app = createApp( + defineComponent({ + setup: () => () => h(TreeItem, { node, depth: 0 }), + }), + ); + mountedApps.push(app); + const runtime = createSidebarTreeRuntime(); + const host = runtimeHost(); + runtime.bindHost(host); + app.use(i18n); + app.provide(sidebarTreeRuntimeKey, runtime); + app.mount(container); + await nextTick(); + + const row = container.querySelector("[tabindex]"); + if (!row) throw new Error(`Connection row was not rendered: ${container.innerHTML}`); + const trigger = row.parentElement; + if (!trigger) throw new Error(`Connection tooltip trigger was not rendered: ${container.innerHTML}`); + vi.spyOn(row, "matches").mockImplementation((selector) => selector === ":hover"); + trigger.dispatchEvent(new MouseEvent("mouseenter")); + await vi.runAllTimersAsync(); + await nextTick(); + return { host, node }; +} + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.replaceChildren(); + vi.useRealTimers(); + state.config = mysqlConnection(); + state.summary = { mode: "database", isActive: true, selected: 2, total: 3 }; +}); + +describe("TreeItem visible filter connection detail", () => { + it("shows an active database filter count in the connection tooltip", async () => { + const { host, node } = await openConnectionTooltip(); + + expect(document.body.textContent).toContain("Visible databases"); + expect(document.body.textContent).toContain("2/3"); + const action = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "2/3"); + expect(action).toBeDefined(); + expect(action?.getAttribute("aria-label")).toBe('Configure visible databases for "Filtered connection"'); + expect(action?.classList.contains("bg-primary/10")).toBe(true); + expect(action?.classList.contains("underline")).toBe(false); + expect(action?.classList.contains("hover:underline")).toBe(true); + expect(action?.classList.contains("hover:bg-primary/10")).toBe(false); + action?.click(); + expect(host.openPrimaryVisibleFilter).toHaveBeenCalledWith(node); + }); + + it("uses the schema label for a schema-mode summary", async () => { + state.config = { ...mysqlConnection(), db_type: "oracle", database: "ORCL" }; + state.summary = { mode: "schema", isActive: true, selected: 1, total: 4 }; + + await openConnectionTooltip(); + + expect(document.body.textContent).toContain("Visible schemas"); + expect(document.body.textContent).toContain("1/4"); + const action = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "1/4"); + expect(action?.getAttribute("aria-label")).toBe('Configure visible schemas for "Filtered connection"'); + }); + + it("shows a clickable detail row when the effective selection is full", async () => { + state.summary = { mode: "database", isActive: false, selected: 3, total: 3 }; + + const { host, node } = await openConnectionTooltip(); + + expect(document.body.textContent).toContain("Visible databases"); + expect(document.body.textContent).toContain("3/3"); + const action = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "3/3"); + expect(action).toBeDefined(); + action?.click(); + expect(host.openPrimaryVisibleFilter).toHaveBeenCalledWith(node); + }); +}); diff --git a/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts b/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts index f233c70a40..bac37b95b2 100644 --- a/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts +++ b/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts @@ -16,6 +16,7 @@ import { canConfigureVisibleSchemasForTreeNode } from "@/lib/database/databaseFe import { canCloseSidebarDatabaseConnection } from "@/lib/sidebar/sidebarDatabaseOpenState"; import { selectedConnectionDeleteTargets, selectedConnectionDuplicateTargets } from "@/lib/sidebar/sidebarConnectionSelection"; import { connectionDeleteTargetSnapshot, showDeleteConfirm, showDeleteGroupConfirm, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState"; +import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu"; interface SidebarConnectionMutationRuntimeOptions { activeNode: ShallowRef; @@ -234,7 +235,7 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu const canConfigureVisibleDatabases = computed(() => { if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false; const databaseType = connectionStore.getConfig(activeNode.value.connectionId)?.db_type; - return databaseType !== "elasticsearch" && databaseType !== "easysearch" && databaseType !== "qdrant" && databaseType !== "milvus" && databaseType !== "weaviate" && databaseType !== "chromadb" && databaseType !== "etcd" && databaseType !== "mq" && databaseType !== "nacos"; + return connectionCanConfigureSidebarVisibleDatabases(databaseType); }); const canConfigureVisibleSchemas = computed(() => { if (!activeNode.value.connectionId) return false; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index cea9f56f84..30342d5e79 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2476,6 +2476,8 @@ export default { }, visibleDatabases: { title: "Visible Databases", + detailLabel: "Visible databases", + detailActionLabel: 'Configure visible databases for "{connection}"', description: 'Choose which databases are shown under "{connection}".', searchPlaceholder: "Search databases...", selectedCount: "{selected}/{total} selected", @@ -2490,6 +2492,8 @@ export default { }, visibleSchemas: { title: "Schema Filter", + detailLabel: "Visible schemas", + detailActionLabel: 'Configure visible schemas for "{connection}"', description: 'Choose which schemas are shown under "{connection}".', searchPlaceholder: "Search schemas...", selectedCount: "{selected}/{total} selected", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index adcee9c26f..71717f262a 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2418,6 +2418,8 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "Bases de datos visibles", + detailLabel: "Bases de datos visibles", + detailActionLabel: 'Configurar bases de datos visibles para "{connection}"', description: 'Elige qué bases de datos se muestran bajo "{connection}".', searchPlaceholder: "Buscar bases de datos...", selectedCount: "{selected}/{total} seleccionadas", @@ -2432,6 +2434,8 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "Filtro de Schema", + detailLabel: "Schemas visibles", + detailActionLabel: 'Configurar schemas visibles para "{connection}"', description: 'Elige qué schemas se muestran bajo "{connection}".', searchPlaceholder: "Buscar schemas...", selectedCount: "{selected}/{total} seleccionados", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 64b7416c6a..eccc0c52a9 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2416,6 +2416,8 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "Database Visibili", + detailLabel: "Database visibili", + detailActionLabel: 'Configura i database visibili per "{connection}"', description: 'Scegli quali database mostrare sotto "{connection}".', searchPlaceholder: "Cerca database...", selectedCount: "{selected}/{total} selezionati", @@ -2430,6 +2432,8 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "Filtro Schema", + detailLabel: "Schema visibili", + detailActionLabel: 'Configura gli schema visibili per "{connection}"', description: 'Scegli quali schema mostrare sotto "{connection}".', searchPlaceholder: "Cerca schema...", selectedCount: "{selected}/{total} selezionati", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index b205754b9d..1dcd1edbde 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2443,6 +2443,8 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "表示するデータベース", + detailLabel: "表示データベース", + detailActionLabel: "「{connection}」の表示データベースを設定", description: "「{connection}」の下に表示するデータベースを選択してください。", searchPlaceholder: "データベースを検索...", selectedCount: "{selected}/{total}件選択中", @@ -2457,6 +2459,8 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "スキーマフィルター", + detailLabel: "表示スキーマ", + detailActionLabel: "「{connection}」の表示スキーマを設定", description: "「{connection}」の下に表示するスキーマを選択してください。", searchPlaceholder: "スキーマを検索...", selectedCount: "{selected}/{total}件選択中", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 819e11de7e..556d965669 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2353,6 +2353,8 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "표시할 데이터베이스", + detailLabel: "표시할 데이터베이스", + detailActionLabel: '"{connection}"의 표시할 데이터베이스 구성', description: '"{connection}" 아래에 표시할 데이터베이스를 선택하세요.', searchPlaceholder: "데이터베이스 검색...", selectedCount: "{selected}/{total} 선택됨", @@ -2367,6 +2369,8 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "스키마 필터", + detailLabel: "표시할 스키마", + detailActionLabel: '"{connection}"의 표시할 스키마 구성', description: '"{connection}" 아래에 표시할 스키마를 선택하세요.', searchPlaceholder: "스키마 검색...", selectedCount: "{selected}/{total} 선택됨", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index b58575b159..a5053e7513 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2418,6 +2418,8 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "Bancos de dados visíveis", + detailLabel: "Bancos de dados visíveis", + detailActionLabel: 'Configurar bancos de dados visíveis para "{connection}"', description: 'Escolha quais bancos de dados são exibidos em "{connection}".', searchPlaceholder: "Pesquisar bancos de dados...", selectedCount: "{selected}/{total} selecionados", @@ -2432,6 +2434,8 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "Filtro de Schema", + detailLabel: "Schemas visíveis", + detailActionLabel: 'Configurar schemas visíveis para "{connection}"', description: 'Escolha quais schemas são exibidos em "{connection}".', searchPlaceholder: "Pesquisar schemas...", selectedCount: "{selected}/{total} selecionados", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index c6ce4e7422..19aa8d5f9a 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2477,6 +2477,8 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "显示数据库", + detailLabel: "可见数据库", + detailActionLabel: "配置「{connection}」的可见数据库", description: "选择「{connection}」下要在侧边栏显示的数据库。", searchPlaceholder: "搜索数据库...", selectedCount: "已选择 {selected}/{total}", @@ -2491,6 +2493,8 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "Schema 过滤器", + detailLabel: "可见 Schema", + detailActionLabel: "配置「{connection}」的可见 Schema", description: "选择「{connection}」下要在侧边栏显示的 Schema。", searchPlaceholder: "搜索 Schema...", selectedCount: "已选择 {selected}/{total}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3a65693d40..225e9f47d2 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2417,6 +2417,8 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "顯示資料庫", + detailLabel: "可見資料庫", + detailActionLabel: "設定「{connection}」的可見資料庫", description: "選擇「{connection}」下要在側邊欄顯示的資料庫。", searchPlaceholder: "搜尋資料庫……", selectedCount: "已選擇 {selected}/{total}", @@ -2431,6 +2433,8 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "Schema 過濾器", + detailLabel: "可見 Schema", + detailActionLabel: "設定「{connection}」的可見 Schema", description: "選擇「{connection}」下要在側邊欄顯示的 Schema。", searchPlaceholder: "搜尋 Schema……", selectedCount: "已選擇 {selected}/{total}", diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeRuntime.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeRuntime.spec.ts index 573e4305b0..5c175abf17 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeRuntime.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeRuntime.spec.ts @@ -12,6 +12,7 @@ function host(): SidebarTreeRuntimeHost { handleRowClick: vi.fn(), handleRowDoubleClick: vi.fn(), handleRowKeydown: vi.fn(), + openPrimaryVisibleFilter: vi.fn(), openDataInNewTab: vi.fn(), requestPaste: vi.fn(() => false), toggleNode: vi.fn(), @@ -46,6 +47,17 @@ describe("sidebar tree runtime", () => { expect(runtimeHost.handleRowClick).toHaveBeenCalledTimes(100); }); + it("forwards the direct primary visible-filter action to the bound host", () => { + const runtime = createSidebarTreeRuntime(); + const runtimeHost = host(); + const connection = { id: "connection-1", label: "Connection", type: "connection", connectionId: "connection-1" } satisfies TreeNode; + runtime.bindHost(runtimeHost); + + runtime.openPrimaryVisibleFilter(connection); + + expect(runtimeHost.openPrimaryVisibleFilter).toHaveBeenCalledWith(connection); + }); + it("rejects superseded and disposed generations without affecting another runtime", () => { const runtime = createSidebarTreeRuntime(); const otherRuntime = createSidebarTreeRuntime(); diff --git a/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts b/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts index f351859531..a1dcabc896 100644 --- a/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts +++ b/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts @@ -3,9 +3,9 @@ import { filterDatabaseNamesForVisiblePicker, normalizeVisibleDatabaseSelection const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_"; -// Turso and Cloudflare D1 connections target one fixed SQLite-compatible `main` namespace; -// listing account-level databases requires separate platform credentials, not the database connection. -const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper"]); +// Turso and Cloudflare D1 target one fixed SQLite-compatible `main` namespace; +// non-database services expose their own root objects rather than database namespaces. +const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"]); type VisibleDatabaseConnectionFields = Pick< ConnectionConfig, diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeRuntime.ts b/apps/desktop/src/lib/sidebar/sidebarTreeRuntime.ts index 833abe5c22..70c0ae57a5 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTreeRuntime.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTreeRuntime.ts @@ -18,6 +18,7 @@ export interface SidebarTreeRuntimeHost { handleRowClick(node: TreeNode, clickDetail: number): void; handleRowDoubleClick(node: TreeNode, event: MouseEvent): void; handleRowKeydown(node: TreeNode, event: KeyboardEvent): void; + openPrimaryVisibleFilter(node: TreeNode): void; openDataInNewTab(node: TreeNode): void; requestPaste(node: TreeNode): boolean; toggleNode(node: TreeNode): void; @@ -38,6 +39,7 @@ export interface SidebarTreeRuntime { handleRowClick(node: TreeNode, clickDetail: number): void; handleRowDoubleClick(node: TreeNode, event: MouseEvent): void; handleRowKeydown(node: TreeNode, event: KeyboardEvent): void; + openPrimaryVisibleFilter(node: TreeNode): void; openDataInNewTab(node: TreeNode): void; requestPaste(node: TreeNode): boolean; toggleNode(node: TreeNode): void; @@ -97,6 +99,9 @@ export function createSidebarTreeRuntime(): SidebarTreeRuntime { handleRowKeydown(node, event) { currentHost()?.handleRowKeydown(node, event); }, + openPrimaryVisibleFilter(node) { + currentHost()?.openPrimaryVisibleFilter(node); + }, openDataInNewTab(node) { currentHost()?.openDataInNewTab(node); }, diff --git a/apps/desktop/src/lib/sidebar/sidebarVisibleFilterMenu.ts b/apps/desktop/src/lib/sidebar/sidebarVisibleFilterMenu.ts index 49eb26b11a..2624817062 100644 --- a/apps/desktop/src/lib/sidebar/sidebarVisibleFilterMenu.ts +++ b/apps/desktop/src/lib/sidebar/sidebarVisibleFilterMenu.ts @@ -1,8 +1,21 @@ +import type { DatabaseType } from "@/types/database"; +import { connectionCanChooseVisibleDatabases } from "@/lib/connection/connectionVisibleDatabases"; + +const CATALOG_SCOPED_VISIBLE_DATABASE_TYPES = new Set(["doris", "starrocks"]); + export type SidebarVisibleFilterMenuEntry = { label: "objects" | "schemas"; target: "visible-databases" | "visible-schemas"; }; +export function connectionCanConfigureSidebarVisibleDatabases(databaseType: DatabaseType | undefined): boolean { + // Doris and StarRocks can expose the same database name in multiple catalogs, + // while `visible_databases` is still a flat name list. Keep the sidebar entry + // unavailable until the persisted selection can preserve catalog identity. + if (databaseType && CATALOG_SCOPED_VISIBLE_DATABASE_TYPES.has(databaseType)) return false; + return connectionCanChooseVisibleDatabases(databaseType ? { db_type: databaseType } : undefined); +} + export function sidebarConnectionVisibleFilterMenu(options: { canConfigureVisibleDatabases: boolean; canConfigureVisibleSchemas: boolean; databaseFilterUsesSchemas: boolean }): SidebarVisibleFilterMenuEntry[] { if (!options.canConfigureVisibleDatabases) { return options.canConfigureVisibleSchemas ? [{ label: "schemas", target: "visible-schemas" }] : []; diff --git a/apps/desktop/src/lib/sidebar/sidebarVisibleFilterSummary.ts b/apps/desktop/src/lib/sidebar/sidebarVisibleFilterSummary.ts new file mode 100644 index 0000000000..c1a8802627 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/sidebarVisibleFilterSummary.ts @@ -0,0 +1,42 @@ +import type { ConnectionConfig } from "@/types/database"; +import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, filterSchemaNamesForVisiblePicker, normalizeVisibleDatabaseSelection } from "@/lib/database/visibleDatabases"; + +type SidebarVisibleFilterConnection = Pick; + +export type SidebarVisibleFilterSummary = { + mode: "database" | "schema"; + isActive: boolean; + selected: number | null; + total: number | null; +}; + +export function connectionHasConfiguredSidebarVisibleFilter(connection: SidebarVisibleFilterConnection): boolean { + if (connectionUsesVisibleSchemaFilter(connection)) { + return Array.isArray(connection.visible_schemas?.[connection.database || ""]); + } + return Array.isArray(connection.visible_databases); +} + +export function sidebarVisibleFilterSummary(connection: SidebarVisibleFilterConnection, objectNames?: readonly string[]): SidebarVisibleFilterSummary { + const mode = connectionUsesVisibleSchemaFilter(connection) ? "schema" : "database"; + const configured = mode === "schema" ? connection.visible_schemas?.[connection.database || ""] : connection.visible_databases; + const isExplicit = Array.isArray(configured); + if (!objectNames) return { mode, isActive: false, selected: null, total: null }; + + const names = [...objectNames]; + const defaultNames = mode === "schema" ? filterSchemaNamesForVisiblePicker(names, connection) : filterDatabaseNamesForVisiblePicker(names, connection); + if (!isExplicit) { + return { mode, isActive: false, selected: defaultNames.length, total: defaultNames.length }; + } + + const selectedNames = normalizeVisibleDatabaseSelection(configured, names); + const defaultNameSet = new Set(defaultNames); + const includesSystemObject = selectedNames.some((name) => !defaultNameSet.has(name)); + const total = includesSystemObject ? names.length : defaultNames.length; + return { + mode, + isActive: selectedNames.length < total, + selected: selectedNames.length, + total, + }; +} diff --git a/apps/desktop/src/stores/__tests__/connectionStore.duckdbVisibleDatabases.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.duckdbVisibleDatabases.spec.ts new file mode 100644 index 0000000000..2f6b34e29b --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.duckdbVisibleDatabases.spec.ts @@ -0,0 +1,121 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionConfig, TreeNode } from "@/types/database"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +function duckDbConnection(visibleDatabases?: string[]): ConnectionConfig { + return { + id: "duckdb-1", + name: "DuckDB", + db_type: "duckdb", + host: "", + port: 0, + username: "", + password: "", + database: "main", + visible_databases: visibleDatabases, + } as ConnectionConfig; +} + +function connectionNode(connection: ConnectionConfig): TreeNode { + return { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: false, + children: [], + }; +} + +function rootEntries(node: TreeNode | undefined) { + return (node?.children ?? []).map((child) => ({ + type: child.type, + database: child.database, + schema: child.schema, + label: child.label, + })); +} + +async function setupStore(visibleDatabases?: string[]) { + const listDatabases = vi.fn().mockResolvedValue([{ name: "main" }, { name: "analytics" }, { name: "warehouse" }]); + const listSchemas = vi.fn().mockResolvedValue(["main", "reporting"]); + const saveConnections = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listDatabases, + listSchemas, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections, + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = duckDbConnection(visibleDatabases); + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.sidebarLayout = { groups: [], order: [{ type: "connection", id: connection.id }] }; + store.treeNodes = [connectionNode(connection)]; + return { store, connection, saveConnections }; +} + +describe("connectionStore DuckDB visible databases", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("keeps main schemas and hides attached databases when only main is visible", async () => { + const { store, connection } = await setupStore(); + + await store.loadDatabases(connection.id, { force: true }); + await store.setVisibleDatabases(connection.id, ["main"]); + + expect(rootEntries(store.treeNodes[0])).toEqual([ + { type: "schema", database: "main", schema: "main", label: "main" }, + { type: "schema", database: "main", schema: "reporting", label: "reporting" }, + ]); + expect(store.getSidebarVisibleFilterSummary(connection.id)).toEqual({ mode: "database", isActive: true, selected: 1, total: 3 }); + }); + + it("hides main schemas and unselected attached databases", async () => { + const { store, connection } = await setupStore(); + + await store.loadDatabases(connection.id, { force: true }); + await store.setVisibleDatabases(connection.id, ["warehouse"]); + + expect(rootEntries(store.treeNodes[0])).toEqual([{ type: "database", database: "warehouse", schema: undefined, label: "warehouse" }]); + expect(store.getSidebarVisibleFilterSummary(connection.id)).toEqual({ mode: "database", isActive: true, selected: 1, total: 3 }); + }); + + it("restores main schemas and every attached database after clearing the filter", async () => { + const { store, connection, saveConnections } = await setupStore(); + await store.loadDatabases(connection.id, { force: true }); + await store.setVisibleDatabases(connection.id, ["warehouse"]); + + await store.clearVisibleDatabases(connection.id); + + expect(rootEntries(store.treeNodes[0])).toEqual([ + { type: "schema", database: "main", schema: "main", label: "main" }, + { type: "schema", database: "main", schema: "reporting", label: "reporting" }, + { type: "database", database: "analytics", schema: undefined, label: "analytics" }, + { type: "database", database: "warehouse", schema: undefined, label: "warehouse" }, + ]); + expect(store.getSidebarVisibleFilterSummary(connection.id)).toEqual({ mode: "database", isActive: false, selected: 3, total: 3 }); + expect(saveConnections).toHaveBeenLastCalledWith([expect.objectContaining({ id: connection.id, visible_databases: undefined })]); + }); +}); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.visibleFilterLifecycle.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.visibleFilterLifecycle.spec.ts new file mode 100644 index 0000000000..cd2eeca82c --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.visibleFilterLifecycle.spec.ts @@ -0,0 +1,126 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { encodeSchemaTreeCache } from "@/lib/metadata/schemaTreeCache"; +import type { ConnectionConfig, TreeNode } from "@/types/database"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +function mysqlConnection(visibleDatabases?: string[]): ConnectionConfig { + return { + id: "mysql-visible-1", + name: "MySQL visible filter", + db_type: "mysql", + host: "127.0.0.1", + port: 3306, + username: "root", + password: "", + database: "filter_alpha", + visible_databases: visibleDatabases, + } as ConnectionConfig; +} + +async function setupStore(visibleDatabases?: string[]) { + const connection = mysqlConnection(visibleDatabases); + const cachedChildren = [databaseNode(connection.id, "filter_alpha"), databaseNode(connection.id, "filter_beta")]; + const listDatabases = vi.fn().mockResolvedValue([ + { name: "filter_alpha", comment: null }, + { name: "filter_beta", comment: null }, + { name: "filter_gamma", comment: null }, + ]); + const disconnectDb = vi.fn().mockResolvedValue(undefined); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + connectDb: vi.fn().mockResolvedValue(connection.id), + connectionDatabaseInfo: vi.fn().mockResolvedValue(null), + connectionIdentifierQuote: vi.fn().mockResolvedValue("`"), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + disconnectDb, + listDatabases, + listInstalledAgents: vi.fn().mockResolvedValue([]), + loadSchemaCache: vi.fn().mockResolvedValue(encodeSchemaTreeCache(cachedChildren)), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [connection]; + store.sidebarLayout = { groups: [], order: [{ type: "connection", id: connection.id }] }; + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: false, + children: [], + }, + ]; + return { connection, disconnectDb, listDatabases, store }; +} + +function databaseNode(connectionId: string, database: string): TreeNode { + return { + id: `${connectionId}:${database}`, + label: database, + type: "database", + connectionId, + database, + isExpanded: false, + children: [], + }; +} + +describe("connectionStore visible filter lifecycle", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("hydrates a saved partial filter count on first expansion and clears it on disconnect", async () => { + const { connection, disconnectDb, listDatabases, store } = await setupStore(["filter_alpha", "filter_beta"]); + + expect(store.getSidebarVisibleFilterSummary(connection.id)).toEqual({ mode: "database", isActive: false, selected: null, total: null }); + + await store.loadDatabases(connection.id); + + await vi.waitFor(() => expect(listDatabases).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => + expect(store.getSidebarVisibleFilterSummary(connection.id)).toEqual({ + mode: "database", + isActive: true, + selected: 2, + total: 3, + }), + ); + expect(store.connectedIds.has(connection.id)).toBe(true); + + await store.disconnect(connection.id); + + expect(disconnectDb).toHaveBeenCalledWith(connection.id, expect.any(Number)); + expect(store.connectedIds.has(connection.id)).toBe(false); + expect(store.getSidebarVisibleFilterSummary(connection.id)).toEqual({ mode: "database", isActive: false, selected: null, total: null }); + }); + + it("does not add a background refresh for an unfiltered cached connection", async () => { + const { connection, listDatabases, store } = await setupStore(); + + await store.loadDatabases(connection.id); + + expect(store.connectedIds.has(connection.id)).toBe(true); + expect(listDatabases).not.toHaveBeenCalled(); + expect(store.getSidebarVisibleFilterSummary(connection.id)).toEqual({ mode: "database", isActive: false, selected: null, total: null }); + }); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 56f05d2ef6..1a1ce391a9 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -123,6 +123,8 @@ import i18n from "@/i18n"; import type { MqAdminConfig } from "@/types/mq"; import { RABBITMQ_MQ_TENANT, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults"; import { applySidebarDatabaseStorage, applySidebarTableStorage, sidebarDatabaseNames, supportsSidebarDatabaseStorage, supportsSidebarTableStorage, type SidebarTableStorageScope } from "@/lib/sidebar/sidebarDatabaseStorage"; +import { connectionHasConfiguredSidebarVisibleFilter, sidebarVisibleFilterSummary } from "@/lib/sidebar/sidebarVisibleFilterSummary"; +import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection"; @@ -344,6 +346,7 @@ export const useConnectionStore = defineStore("connection", () => { const completionColumnsCache = ref>({}); const completionForeignKeysCache = ref>({}); const completionDatabasesCache = ref>({}); + const primaryVisibleObjectNames = ref>({}); const sqlServerCompletionContextCache = ref>({}); const elasticsearchCompletionIndicesCache = ref>({}); const redisCompletionKeysCache = ref>({}); @@ -444,6 +447,7 @@ export const useConnectionStore = defineStore("connection", () => { const connectionStateRevisions = new Map(); const connectionErrorRevisions = new Map(); const treeNodeLoads = new TreeNodeLoadRegistry(); + const primaryVisibleObjectRefreshInFlight = new Set(); let nextLocalConnectionAttempt = 0; let beforeConnectHandler: BeforeConnectHandler | null = null; let initFromDiskPromise: Promise | null = null; @@ -902,6 +906,7 @@ export const useConnectionStore = defineStore("connection", () => { function markConnectionLost(connectionId: string, error: unknown) { connectedIds.value.delete(connectionId); + clearPrimaryVisibleObjectNames(connectionId); clearConnectionIdentifierQuote(connectionId); clearConnectionNodeLoading(connectionId); clearConnectionHealthCheck(connectionId); @@ -2377,6 +2382,7 @@ export const useConnectionStore = defineStore("connection", () => { clearConnectionError(id); connectionErrorRevisions.delete(id); connectedIds.value.delete(id); + clearPrimaryVisibleObjectNames(id); clearConnectionIdentifierQuote(id); clearConnectionHealthCheck(id); sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id); @@ -2412,6 +2418,7 @@ export const useConnectionStore = defineStore("connection", () => { connections.value = nextConnections; rebuildTreeNodes(); if (!runtimeConfigChanged) return; + clearPrimaryVisibleObjectNames(config.id); connectedIds.value.delete(config.id); clearConnectionIdentifierQuote(config.id); clearConnectionHealthCheck(config.id); @@ -2557,6 +2564,34 @@ export const useConnectionStore = defineStore("connection", () => { await reloadConnectionDatabaseChildren(connectionId); } + function recordPrimaryVisibleObjectNames(connectionId: string, objectNames: readonly string[]) { + const names = [...objectNames]; + const existing = primaryVisibleObjectNames.value[connectionId]; + if (existing?.length === names.length && existing.every((name, index) => name === names[index])) return; + primaryVisibleObjectNames.value = { ...primaryVisibleObjectNames.value, [connectionId]: names }; + } + + function clearPrimaryVisibleObjectNames(connectionId: string) { + if (!(connectionId in primaryVisibleObjectNames.value)) return; + const next = { ...primaryVisibleObjectNames.value }; + delete next[connectionId]; + primaryVisibleObjectNames.value = next; + } + + function getSidebarVisibleFilterSummary(connectionId: string) { + const config = getConfig(connectionId); + return config ? sidebarVisibleFilterSummary(config, primaryVisibleObjectNames.value[connectionId]) : null; + } + + function scheduleMissingPrimaryVisibleObjectNamesRefresh(connectionId: string, config: ConnectionConfig | undefined, options?: LoadTreeOptions) { + if (options?.force || !config || primaryVisibleObjectNames.value[connectionId] || primaryVisibleObjectRefreshInFlight.has(connectionId)) return; + if (!connectionCanConfigureSidebarVisibleDatabases(config.db_type) || !connectionHasConfiguredSidebarVisibleFilter(config)) return; + primaryVisibleObjectRefreshInFlight.add(connectionId); + void loadDatabases(connectionId, { force: true, connectedOnly: true }) + .catch(() => undefined) + .finally(() => primaryVisibleObjectRefreshInFlight.delete(connectionId)); + } + async function clearVisibleDatabases(connectionId: string) { const config = getConfig(connectionId); if (!config || !Array.isArray(config.visible_databases)) return; @@ -2738,6 +2773,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!cancelled) return false; clearConnectionError(connectionId); connectedIds.value.delete(connectionId); + clearPrimaryVisibleObjectNames(connectionId); clearConnectionIdentifierQuote(connectionId); clearConnectionHealthCheck(connectionId); if (activeConnectionId.value === connectionId) activeConnectionId.value = null; @@ -2753,6 +2789,7 @@ export const useConnectionStore = defineStore("connection", () => { const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true; connectedIds.value.delete(connectionId); + clearPrimaryVisibleObjectNames(connectionId); clearConnectionIdentifierQuote(connectionId); forgetSuccessfulLocalConnectionAttempt(connectionId); clearConnectionHealthCheck(connectionId); @@ -2829,6 +2866,7 @@ export const useConnectionStore = defineStore("connection", () => { } catch { // Backend pool is dead — remove from connectedIds and reconnect connectedIds.value.delete(connectionId); + clearPrimaryVisibleObjectNames(connectionId); clearConnectionHealthCheck(connectionId); if (activeConnectionId.value === connectionId) activeConnectionId.value = null; } @@ -3028,23 +3066,33 @@ export const useConnectionStore = defineStore("connection", () => { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); } - if (useCachedChildren(node, options, load)) return; - const config = getConfig(connectionId); + if (useCachedChildren(node, options, load)) { + scheduleMissingPrimaryVisibleObjectNamesRefresh(connectionId, config, options); + return; + } + if (config?.db_type === "duckdb") { const cacheKey = schemaCacheKey(connectionId, "duckdb-root"); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); + else scheduleMissingPrimaryVisibleObjectNamesRefresh(connectionId, config, options); return; } } const [databases, schemas] = await Promise.all([withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases"), withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, "main"), "schemas")]); - const children = withSavedSqlRoot(connectionId, buildDuckDbConnectionTreeNodes(connectionId, databases, schemas), node); + const databaseNames = databases.map((database) => database.name); + const visibleNames = filterDatabaseNamesForConnection(databaseNames, config); + const visibleNameSet = new Set(visibleNames); + const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name)); + const visibleSchemas = visibleNameSet.has("main") ? schemas : []; + const children = withSavedSqlRoot(connectionId, buildDuckDbConnectionTreeNodes(connectionId, visibleDatabases, visibleSchemas), node); if (isSidebarSearchQueryChanged(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; + recordPrimaryVisibleObjectNames(connectionId, databaseNames); setChildren(targetNode, children); await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children); } else if (config && connectionUsesVisibleSchemaFilter(config)) { @@ -3056,10 +3104,11 @@ export const useConnectionStore = defineStore("connection", () => { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); + else scheduleMissingPrimaryVisibleObjectNamesRefresh(connectionId, config, options); return; } } - const schemas = await withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, effectiveDb, true), "schemas"); + const schemas = await withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, effectiveDb), "schemas"); const visibleSchemas = filterSchemaNamesForConnection(schemas, schemaFilterConfig, effectiveDb || "", { showSystemSchemas }); const schemaNodes: TreeNode[] = sortSidebarNames(visibleSchemas).map((s) => ({ id: `${connectionId}:${s}:${s}`, @@ -3074,6 +3123,7 @@ export const useConnectionStore = defineStore("connection", () => { if (isSidebarSearchQueryChanged(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; + recordPrimaryVisibleObjectNames(connectionId, schemas); setChildren(targetNode, withSavedSqlRoot(connectionId, schemaNodes, targetNode)); await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || schemaNodes); } else { @@ -3120,6 +3170,7 @@ export const useConnectionStore = defineStore("connection", () => { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); + else scheduleMissingPrimaryVisibleObjectNamesRefresh(connectionId, config, options); return; } } @@ -3157,6 +3208,10 @@ export const useConnectionStore = defineStore("connection", () => { if (isSidebarSearchQueryChanged(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; + recordPrimaryVisibleObjectNames( + connectionId, + databases.map((database) => database.name), + ); setChildren(targetNode, children); await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children); } @@ -3212,6 +3267,10 @@ export const useConnectionStore = defineStore("connection", () => { const visibleNameSet = new Set(visibleNames); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; + recordPrimaryVisibleObjectNames( + connectionId, + dbs.map((db) => String(db.db)), + ); setChildren( targetNode, withSavedSqlRoot( @@ -3516,6 +3575,7 @@ export const useConnectionStore = defineStore("connection", () => { const visibleDbs = filterDatabaseNamesForConnection(dbs, config); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; + recordPrimaryVisibleObjectNames(connectionId, dbs); setChildren( targetNode, withSavedSqlRoot( @@ -6903,6 +6963,8 @@ export const useConnectionStore = defineStore("connection", () => { ensureVisibleDatabase, setVisibleSchemas, clearVisibleSchemas, + recordPrimaryVisibleObjectNames, + getSidebarVisibleFilterSummary, removeConnection, removeConnections, editingConnectionId, diff --git a/packages/app-tests/connectionVisibleDatabases.test.ts b/packages/app-tests/connectionVisibleDatabases.test.ts index f9b92387d9..198b562f8f 100644 --- a/packages/app-tests/connectionVisibleDatabases.test.ts +++ b/packages/app-tests/connectionVisibleDatabases.test.ts @@ -90,6 +90,11 @@ test("Turso does not offer a visible database filter for its fixed main namespac assert.equal(connectionCanChooseVisibleDatabases(config({ db_type: "turso" })), false); }); +test("non-database connection types do not offer visible database selection", () => { + assert.equal(connectionCanChooseVisibleDatabases(config({ db_type: "mq" })), false); + assert.equal(connectionCanChooseVisibleDatabases(config({ db_type: "nacos" })), false); +}); + test("OceanBase Oracle uses schema filtering for visible object selection", () => { assert.equal(connectionUsesVisibleSchemaFilter(config({ db_type: "oceanbase-oracle" })), true); assert.equal(connectionUsesVisibleSchemaFilter(config({ db_type: "mysql", driver_profile: "oceanbase" })), false); diff --git a/packages/app-tests/sidebarRuntimeDecomposition.test.ts b/packages/app-tests/sidebarRuntimeDecomposition.test.ts index 2f52693dd4..e3be0c4e53 100644 --- a/packages/app-tests/sidebarRuntimeDecomposition.test.ts +++ b/packages/app-tests/sidebarRuntimeDecomposition.test.ts @@ -33,6 +33,18 @@ test("one tree-level runtime serves every row renderer", () => { assert.match(dataOpenRuntime, /canApplyDataTabMetadata/); }); +test("connection detail tooltips expose every known visible-filter count", () => { + assert.match(treeItem, /connectionStore\.getSidebarVisibleFilterSummary\(node\.connectionId\)/); + assert.match(treeItem, /visibleFilterSummary\?\.selected != null && visibleFilterSummary\.total != null/); + assert.doesNotMatch(treeItem, /visibleFilterSummary\?\.isActive/); + assert.match(treeItem, /visibleSchemas\.detailLabel/); + assert.match(treeItem, /visibleDatabases\.detailLabel/); + assert.match(treeItem, /treeRuntime\.openPrimaryVisibleFilter\(node\)/); + assert.match(runtimeHost, /function openPrimaryVisibleFilter\(node: TreeNode\)/); + assert.match(runtimeHost, /openVisibleDatabasesDialog\(\)/); + assert.doesNotMatch(treeItem, /SidebarVisibleFilterControl/); +}); + test("the persistent runtime releases detached tree nodes", () => { const actionTarget = readFileSync("apps/desktop/src/lib/sidebar/sidebarActionTarget.ts", "utf8"); const connectionMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts", "utf8"); diff --git a/packages/app-tests/sidebarVisibleFilterMenu.test.ts b/packages/app-tests/sidebarVisibleFilterMenu.test.ts index 8427a74e44..626959759b 100644 --- a/packages/app-tests/sidebarVisibleFilterMenu.test.ts +++ b/packages/app-tests/sidebarVisibleFilterMenu.test.ts @@ -1,6 +1,20 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { sidebarConnectionVisibleFilterMenu } from "../../apps/desktop/src/lib/sidebar/sidebarVisibleFilterMenu.ts"; +import { connectionCanConfigureSidebarVisibleDatabases, sidebarConnectionVisibleFilterMenu } from "../../apps/desktop/src/lib/sidebar/sidebarVisibleFilterMenu.ts"; + +test("connection-level visible filter support preserves the existing sidebar capability boundary", () => { + assert.equal(connectionCanConfigureSidebarVisibleDatabases("mysql"), true); + assert.equal(connectionCanConfigureSidebarVisibleDatabases("oracle"), true); + assert.equal(connectionCanConfigureSidebarVisibleDatabases("redis"), true); + assert.equal(connectionCanConfigureSidebarVisibleDatabases("sqlite"), true); + assert.equal(connectionCanConfigureSidebarVisibleDatabases("doris"), false); + assert.equal(connectionCanConfigureSidebarVisibleDatabases("starrocks"), false); + assert.equal(connectionCanConfigureSidebarVisibleDatabases("turso"), false); + assert.equal(connectionCanConfigureSidebarVisibleDatabases("zookeeper"), false); + assert.equal(connectionCanConfigureSidebarVisibleDatabases("elasticsearch"), false); + assert.equal(connectionCanConfigureSidebarVisibleDatabases("mq"), false); + assert.equal(connectionCanConfigureSidebarVisibleDatabases(undefined), false); +}); test("Dameng and Oracle schema-mode filters keep the connection-level dialog with one schema label", () => { assert.deepEqual( diff --git a/packages/app-tests/sidebarVisibleFilterSummary.test.ts b/packages/app-tests/sidebarVisibleFilterSummary.test.ts new file mode 100644 index 0000000000..1230437ed2 --- /dev/null +++ b/packages/app-tests/sidebarVisibleFilterSummary.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import type { ConnectionConfig } from "../../apps/desktop/src/types/database.ts"; +import { connectionHasConfiguredSidebarVisibleFilter, sidebarVisibleFilterSummary } from "../../apps/desktop/src/lib/sidebar/sidebarVisibleFilterSummary.ts"; + +function connection(overrides: Partial = {}): ConnectionConfig { + return { + id: "connection-1", + name: "Connection", + db_type: "mysql", + host: "localhost", + port: 3306, + username: "root", + ...overrides, + }; +} + +test("summary remains inactive before primary namespace metadata is loaded", () => { + assert.deepEqual(sidebarVisibleFilterSummary(connection({ visible_databases: ["app"] })), { + mode: "database", + isActive: false, + selected: null, + total: null, + }); +}); + +test("configured filter detection follows the connection's primary namespace mode", () => { + assert.equal(connectionHasConfiguredSidebarVisibleFilter(connection()), false); + assert.equal(connectionHasConfiguredSidebarVisibleFilter(connection({ visible_databases: [] })), true); + assert.equal( + connectionHasConfiguredSidebarVisibleFilter( + connection({ + db_type: "oracle", + database: "ORCL", + visible_databases: ["ignored-for-schema-mode"], + visible_schemas: { ORCL: ["APP"] }, + }), + ), + true, + ); + assert.equal( + connectionHasConfiguredSidebarVisibleFilter( + connection({ + db_type: "oracle", + database: "ORCL", + visible_databases: ["ignored-for-schema-mode"], + }), + ), + false, + ); +}); + +test("unfiltered database summary uses the picker's default non-system scope", () => { + assert.deepEqual(sidebarVisibleFilterSummary(connection(), ["app", "analytics", "mysql", "sys"]), { + mode: "database", + isActive: false, + selected: 2, + total: 2, + }); +}); + +test("explicit full database selection returns to the inactive visual state", () => { + assert.deepEqual(sidebarVisibleFilterSummary(connection({ visible_databases: ["app", "analytics"] }), ["app", "analytics", "mysql", "sys"]), { + mode: "database", + isActive: false, + selected: 2, + total: 2, + }); +}); + +test("explicit database summary ignores stale names and retains the default denominator", () => { + assert.deepEqual(sidebarVisibleFilterSummary(connection({ visible_databases: ["app", "removed"] }), ["app", "analytics", "mysql"]), { + mode: "database", + isActive: true, + selected: 1, + total: 2, + }); +}); + +test("selecting a system database expands the denominator like the picker", () => { + assert.deepEqual(sidebarVisibleFilterSummary(connection({ visible_databases: ["app", "mysql"] }), ["app", "analytics", "mysql", "sys"]), { + mode: "database", + isActive: true, + selected: 2, + total: 4, + }); +}); + +test("schema-mode summary reads the primary schema filter for the configured database", () => { + assert.deepEqual( + sidebarVisibleFilterSummary( + connection({ + db_type: "oracle", + database: "ORCL", + username: "APP", + visible_schemas: { ORCL: ["APP"] }, + }), + ["APP", "REPORTING", "SYS"], + ), + { + mode: "schema", + isActive: true, + selected: 1, + total: 2, + }, + ); +}); + +test("schema-mode summary includes system schemas when they are explicitly selected", () => { + assert.deepEqual( + sidebarVisibleFilterSummary( + connection({ + db_type: "oracle", + database: "ORCL", + username: "APP", + visible_schemas: { ORCL: ["APP", "SYS"] }, + }), + ["APP", "REPORTING", "SYS"], + ), + { + mode: "schema", + isActive: true, + selected: 2, + total: 3, + }, + ); +});