diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index cf826d7b6..65fc0a0e3 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -4802,6 +4802,11 @@ function handleRowKeydown(node: TreeNode, event: KeyboardEvent) { onKeydown(event); } +function openPrimaryVisibleFilter(node: TreeNode) { + activateRuntimeNode(node); + openVisibleDatabasesDialog(); +} + function openDataInNewTab(node: TreeNode) { activateRuntimeNode(node); openDataInNewTabImmediately(node); @@ -4822,6 +4827,7 @@ defineExpose({ handleRowClick, handleRowDoubleClick, handleRowKeydown, + openPrimaryVisibleFilter, openDataInNewTab, requestPaste, toggleNode, diff --git a/apps/desktop/src/components/sidebar/SidebarVisibleFilterControl.vue b/apps/desktop/src/components/sidebar/SidebarVisibleFilterControl.vue new file mode 100644 index 000000000..1b5de5423 --- /dev/null +++ b/apps/desktop/src/components/sidebar/SidebarVisibleFilterControl.vue @@ -0,0 +1,57 @@ + + + diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index f06481af0..a87c427dd 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -70,6 +70,7 @@ import { useDragSort } from "@/composables/useDragSort"; import { sidebarTreeRuntimeKey } from "@/lib/sidebar/sidebarTreeRuntime"; import { treeNodePinKey } from "@/lib/app/pinnedItems"; import { isTreeGroupNodeType } from "@/lib/sidebar/treeNodeGroup"; +import SidebarVisibleFilterControl from "./SidebarVisibleFilterControl.vue"; const { t } = useI18n(); @@ -1196,6 +1197,7 @@ function onKeydown(event: KeyboardEvent) { >{{ trailingComment }} + {{ t("connection.readOnlyBadge") }} diff --git a/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue b/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue index 4f1cf403a..79f82aeb6 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__/SidebarVisibleFilterControl.spec.ts b/apps/desktop/src/components/sidebar/__tests__/SidebarVisibleFilterControl.spec.ts new file mode 100644 index 000000000..8a7a31adb --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarVisibleFilterControl.spec.ts @@ -0,0 +1,95 @@ +// @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 SidebarVisibleFilterControl from "@/components/sidebar/SidebarVisibleFilterControl.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: Pick; + summary: SidebarVisibleFilterSummary; +} = { + config: { db_type: "mysql", name: "MySQL" }, + summary: { mode: "database", isExplicit: true, selected: 1, total: 6 }, +}; + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + getConfig: () => state.config, + getSidebarVisibleFilterSummary: () => state.summary, + }), +})); + +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 mountControl() { + const node: TreeNode = { id: "connection-1", label: "MySQL", type: "connection", connectionId: "connection-1" }; + const container = document.createElement("div"); + document.body.append(container); + const runtime = createSidebarTreeRuntime(); + const host = runtimeHost(); + runtime.bindHost(host); + const app = createApp( + defineComponent({ + setup: () => () => h(SidebarVisibleFilterControl, { node }), + }), + ); + mountedApps.push(app); + app.use(i18n); + app.provide(sidebarTreeRuntimeKey, runtime); + app.mount(container); + await nextTick(); + return { container, host, node }; +} + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; + state.config = { db_type: "mysql", name: "MySQL" }; + state.summary = { mode: "database", isExplicit: true, selected: 1, total: 6 }; +}); + +describe("SidebarVisibleFilterControl", () => { + it("shows the selected/total count and opens the existing primary filter route", async () => { + const { container, host, node } = await mountControl(); + const button = container.querySelector("[data-sidebar-visible-filter]"); + + expect(button?.textContent).toBe("1/6"); + expect(button?.getAttribute("aria-label")).toContain("MySQL"); + button?.click(); + expect(host.openPrimaryVisibleFilter).toHaveBeenCalledWith(node); + }); + + it("keeps an accessible filter control before counts are available", async () => { + state.summary = { mode: "database", isExplicit: false, selected: null, total: null }; + const { container } = await mountControl(); + const button = container.querySelector("[data-sidebar-visible-filter]"); + + expect(button).not.toBeNull(); + expect(button?.textContent).toBe(""); + expect(button?.getAttribute("aria-label")).toContain("MySQL"); + }); + + it("does not render for unsupported connection types", async () => { + state.config = { db_type: "elasticsearch", name: "Search" }; + const { container } = await mountControl(); + + expect(container.querySelector("[data-sidebar-visible-filter]")).toBeNull(); + }); +}); 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 a524f9f5d..9910e0137 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/composables/useSidebarConnectionMutationRuntime.ts b/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts index f233c70a4..bac37b95b 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 fc4ed75f3..caa5772df 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2431,6 +2431,7 @@ export default { }, visibleDatabases: { title: "Visible Databases", + sidebarControlLabel: 'Configure visible databases for "{connection}"{count}', description: 'Choose which databases are shown under "{connection}".', searchPlaceholder: "Search databases...", selectedCount: "{selected}/{total} selected", @@ -2445,6 +2446,7 @@ export default { }, visibleSchemas: { title: "Schema Filter", + sidebarControlLabel: 'Configure visible schemas for "{connection}"{count}', 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 b84ae1793..a08c1e8bf 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2373,6 +2373,7 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "Bases de datos visibles", + sidebarControlLabel: 'Configurar bases de datos visibles para "{connection}"{count}', description: 'Elige qué bases de datos se muestran bajo "{connection}".', searchPlaceholder: "Buscar bases de datos...", selectedCount: "{selected}/{total} seleccionadas", @@ -2387,6 +2388,7 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "Filtro de Schema", + sidebarControlLabel: 'Configurar schemas visibles para "{connection}"{count}', 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 5f4ed41d7..89ccee7e5 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2371,6 +2371,7 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "Database Visibili", + sidebarControlLabel: 'Configura i database visibili per "{connection}"{count}', description: 'Scegli quali database mostrare sotto "{connection}".', searchPlaceholder: "Cerca database...", selectedCount: "{selected}/{total} selezionati", @@ -2385,6 +2386,7 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "Filtro Schema", + sidebarControlLabel: 'Configura gli schema visibili per "{connection}"{count}', 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 7326415a7..e87d7566c 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2398,6 +2398,7 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "表示するデータベース", + sidebarControlLabel: "「{connection}」の表示データベースを設定{count}", description: "「{connection}」の下に表示するデータベースを選択してください。", searchPlaceholder: "データベースを検索...", selectedCount: "{selected}/{total}件選択中", @@ -2412,6 +2413,7 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "スキーマフィルター", + sidebarControlLabel: "「{connection}」の表示スキーマを設定{count}", 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 9beca54da..6a02e265e 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2313,6 +2313,7 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "표시할 데이터베이스", + sidebarControlLabel: '"{connection}"의 표시할 데이터베이스 구성{count}', description: '"{connection}" 아래에 표시할 데이터베이스를 선택하세요.', searchPlaceholder: "데이터베이스 검색...", selectedCount: "{selected}/{total} 선택됨", @@ -2327,6 +2328,7 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "스키마 필터", + sidebarControlLabel: '"{connection}"의 표시할 스키마 구성{count}', 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 9b6e26d2a..c388f7397 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2373,6 +2373,7 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "Bancos de dados visíveis", + sidebarControlLabel: 'Configurar bancos de dados visíveis para "{connection}"{count}', description: 'Escolha quais bancos de dados são exibidos em "{connection}".', searchPlaceholder: "Pesquisar bancos de dados...", selectedCount: "{selected}/{total} selecionados", @@ -2387,6 +2388,7 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "Filtro de Schema", + sidebarControlLabel: 'Configurar schemas visíveis para "{connection}"{count}', 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 13c767cf3..736c1823e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2432,6 +2432,7 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "显示数据库", + sidebarControlLabel: "配置「{connection}」的可见数据库{count}", description: "选择「{connection}」下要在侧边栏显示的数据库。", searchPlaceholder: "搜索数据库...", selectedCount: "已选择 {selected}/{total}", @@ -2446,6 +2447,7 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "Schema 过滤器", + sidebarControlLabel: "配置「{connection}」的可见 Schema{count}", 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 e3c9e4de4..3cde24c15 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2372,6 +2372,7 @@ export default withEnglishFallback({ }, visibleDatabases: { title: "顯示資料庫", + sidebarControlLabel: "設定「{connection}」的可見資料庫{count}", description: "選擇「{connection}」下要在側邊欄顯示的資料庫。", searchPlaceholder: "搜尋資料庫……", selectedCount: "已選擇 {selected}/{total}", @@ -2386,6 +2387,7 @@ export default withEnglishFallback({ }, visibleSchemas: { title: "Schema 過濾器", + sidebarControlLabel: "設定「{connection}」的可見 Schema{count}", 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 573e4305b..5c175abf1 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 f35185953..a1dcabc89 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 833abe5c2..70c0ae57a 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 49eb26b11..262481706 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 000000000..6da557a0f --- /dev/null +++ b/apps/desktop/src/lib/sidebar/sidebarVisibleFilterSummary.ts @@ -0,0 +1,33 @@ +import type { ConnectionConfig } from "@/types/database"; +import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, filterSchemaNamesForVisiblePicker, normalizeVisibleDatabaseSelection } from "@/lib/database/visibleDatabases"; + +type SidebarVisibleFilterConnection = Pick; + +export type SidebarVisibleFilterSummary = { + mode: "database" | "schema"; + isExplicit: boolean; + selected: number | null; + total: number | null; +}; + +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; + if (!objectNames) return { mode, isExplicit: Array.isArray(configured), selected: null, total: null }; + + const names = [...objectNames]; + const defaultNames = mode === "schema" ? filterSchemaNamesForVisiblePicker(names, connection) : filterDatabaseNamesForVisiblePicker(names, connection); + if (!Array.isArray(configured)) { + return { mode, isExplicit: 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)); + return { + mode, + isExplicit: true, + selected: selectedNames.length, + total: includesSystemObject ? names.length : defaultNames.length, + }; +} 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 000000000..c84de298c --- /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", isExplicit: 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", isExplicit: 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", isExplicit: false, selected: 3, total: 3 }); + expect(saveConnections).toHaveBeenLastCalledWith([expect.objectContaining({ id: connection.id, visible_databases: undefined })]); + }); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 0fc5e6ba4..ef29ef736 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -122,6 +122,7 @@ 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 { sidebarVisibleFilterSummary } from "@/lib/sidebar/sidebarVisibleFilterSummary"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection"; @@ -343,6 +344,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>({}); @@ -2375,6 +2377,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); @@ -2410,6 +2413,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); @@ -2555,6 +2559,25 @@ 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; + } + async function clearVisibleDatabases(connectionId: string) { const config = getConfig(connectionId); if (!config || !Array.isArray(config.visible_databases)) return; @@ -3037,10 +3060,16 @@ export const useConnectionStore = defineStore("connection", () => { } } 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)) { @@ -3055,7 +3084,7 @@ export const useConnectionStore = defineStore("connection", () => { 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}`, @@ -3070,6 +3099,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 { @@ -3153,6 +3183,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); } @@ -3208,6 +3242,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( @@ -3511,6 +3549,7 @@ export const useConnectionStore = defineStore("connection", () => { const visibleDbs = filterDatabaseNamesForConnection(dbs, config); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; + recordPrimaryVisibleObjectNames(connectionId, dbs); setChildren( targetNode, withSavedSqlRoot( @@ -6898,6 +6937,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 f9b92387d..198b562f8 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 2f52693dd..0b7aabaa4 100644 --- a/packages/app-tests/sidebarRuntimeDecomposition.test.ts +++ b/packages/app-tests/sidebarRuntimeDecomposition.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { test } from "vitest"; const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8"); +const visibleFilterControl = readFileSync("apps/desktop/src/components/sidebar/SidebarVisibleFilterControl.vue", "utf8"); const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8"); const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8"); const dataOpenRuntime = readFileSync("apps/desktop/src/composables/useSidebarDataOpenRuntime.ts", "utf8"); @@ -33,6 +34,15 @@ test("one tree-level runtime serves every row renderer", () => { assert.match(dataOpenRuntime, /canApplyDataTabMetadata/); }); +test("connection rows expose the primary visible-filter control through the shared runtime", () => { + assert.match(treeItem, //); + assert.match(visibleFilterControl, /data-sidebar-visible-filter/); + assert.match(visibleFilterControl, /control\.selected/); + assert.match(visibleFilterControl, /treeRuntime\.openPrimaryVisibleFilter\(props\.node\)/); + assert.match(runtimeHost, /function openPrimaryVisibleFilter\(node: TreeNode\)/); + assert.match(runtimeHost, /openVisibleDatabasesDialog\(\)/); +}); + 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 8427a74e4..626959759 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 000000000..852952be4 --- /dev/null +++ b/packages/app-tests/sidebarVisibleFilterSummary.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import type { ConnectionConfig } from "../../apps/desktop/src/types/database.ts"; +import { 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 actionable before primary namespace metadata is loaded", () => { + assert.deepEqual(sidebarVisibleFilterSummary(connection({ visible_databases: ["app"] })), { + mode: "database", + isExplicit: true, + selected: null, + total: null, + }); +}); + +test("unfiltered database summary uses the picker's default non-system scope", () => { + assert.deepEqual(sidebarVisibleFilterSummary(connection(), ["app", "analytics", "mysql", "sys"]), { + mode: "database", + isExplicit: 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", + isExplicit: 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", + isExplicit: 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", + isExplicit: 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", + isExplicit: true, + selected: 2, + total: 3, + }, + ); +});