mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:55:32 +00:00
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
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
<span v-for="(v, vi) in row.values" :key="vi" class="break-all">{{ v }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
v-else-if="row.action"
|
||||
type="button"
|
||||
class="w-fit rounded bg-primary/10 px-1 font-mono text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="row.actionLabel"
|
||||
:title="row.actionLabel"
|
||||
@click.stop="row.action()"
|
||||
>
|
||||
{{ row.value }}
|
||||
</button>
|
||||
<span v-else-if="row.multiline" class="max-h-20 overflow-hidden whitespace-pre-wrap break-words text-foreground/90">
|
||||
{{ row.value }}
|
||||
</span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
@@ -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(),
|
||||
|
||||
+180
@@ -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<string>(),
|
||||
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<string>(),
|
||||
sidebarTableSearchQueries: {},
|
||||
tableNameFilterForScope: () => undefined,
|
||||
treeNodes: [],
|
||||
treeSelectionAnchorId: null as string | null,
|
||||
};
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => connectionStore,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/queryStore", () => ({
|
||||
useQueryStore: () => ({ openDatabaseKeys: new Set<string>() }),
|
||||
}));
|
||||
|
||||
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<HTMLElement>("[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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "3/3");
|
||||
expect(action).toBeDefined();
|
||||
action?.click();
|
||||
expect(host.openPrimaryVisibleFilter).toHaveBeenCalledWith(node);
|
||||
});
|
||||
});
|
||||
@@ -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<TreeNode>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}件選択中",
|
||||
|
||||
@@ -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} 선택됨",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<DatabaseType>(["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<DatabaseType>(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"]);
|
||||
|
||||
type VisibleDatabaseConnectionFields = Pick<
|
||||
ConnectionConfig,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { connectionCanChooseVisibleDatabases } from "@/lib/connection/connectionVisibleDatabases";
|
||||
|
||||
const CATALOG_SCOPED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["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" }] : [];
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, filterSchemaNamesForVisiblePicker, normalizeVisibleDatabaseSelection } from "@/lib/database/visibleDatabases";
|
||||
|
||||
type SidebarVisibleFilterConnection = Pick<ConnectionConfig, "database" | "db_type" | "driver_profile" | "show_system_schemas" | "username" | "visible_databases" | "visible_schemas">;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
+121
@@ -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<string, string>();
|
||||
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 })]);
|
||||
});
|
||||
});
|
||||
+126
@@ -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<string, string>();
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, ColumnInfo[]>>({});
|
||||
const completionForeignKeysCache = ref<Record<string, ForeignKeyInfo[]>>({});
|
||||
const completionDatabasesCache = ref<Record<string, string[]>>({});
|
||||
const primaryVisibleObjectNames = ref<Record<string, string[]>>({});
|
||||
const sqlServerCompletionContextCache = ref<Record<string, SqlServerCompletionContext>>({});
|
||||
const elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
|
||||
const redisCompletionKeysCache = ref<Record<string, string[]>>({});
|
||||
@@ -444,6 +447,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const connectionStateRevisions = new Map<string, number>();
|
||||
const connectionErrorRevisions = new Map<string, number>();
|
||||
const treeNodeLoads = new TreeNodeLoadRegistry();
|
||||
const primaryVisibleObjectRefreshInFlight = new Set<string>();
|
||||
let nextLocalConnectionAttempt = 0;
|
||||
let beforeConnectHandler: BeforeConnectHandler | null = null;
|
||||
let initFromDiskPromise: Promise<void> | 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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
+15
-1
@@ -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(
|
||||
|
||||
@@ -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> = {}): 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,
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user