From e4c1d6639b3267a653874d1a1a23d2ce846692a3 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 11 May 2026 17:15:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(sidebar):=20=E4=BF=9D=E6=8C=81=E8=A1=A8?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=BB=9A=E5=8A=A8=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/sidebar/ConnectionTree.vue | 13 +++- src/composables/useFlatTree.ts | 4 ++ src/lib/tableTree.ts | 41 +++++++++++ src/stores/connectionStore.ts | 81 +++------------------- tests/tableTree.test.ts | 84 +++++++++++++++++++++++ tests/useFlatTree.test.ts | 17 ++++- 6 files changed, 166 insertions(+), 74 deletions(-) create mode 100644 src/lib/tableTree.ts create mode 100644 tests/tableTree.test.ts diff --git a/src/components/sidebar/ConnectionTree.vue b/src/components/sidebar/ConnectionTree.vue index 9459a4c459..7abc431519 100644 --- a/src/components/sidebar/ConnectionTree.vue +++ b/src/components/sidebar/ConnectionTree.vue @@ -5,7 +5,14 @@ import { Search, X, ListFilter, Check, FolderPlus } from "lucide-vue-next"; import { useConnectionStore } from "@/stores/connectionStore"; import type { TreeNode } from "@/types/database"; import { matchSidebarLabel } from "@/lib/sidebarSearch"; -import { flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree"; +import { + SIDEBAR_TREE_ROW_HEIGHT, + SIDEBAR_TREE_PRERENDER_COUNT, + SIDEBAR_TREE_SCROLL_BUFFER, + flattenTree, + shouldVirtualizeFlatTree, + type FlatTreeNode, +} from "@/composables/useFlatTree"; import TreeItem from "./TreeItem.vue"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import { RecycleScroller } from "vue-virtual-scroller"; @@ -242,7 +249,9 @@ function onSearchToggle(node: TreeNode) { v-if="flatNodes.length > 0 && useVirtualTree" class="min-h-0 flex-1 overflow-y-auto" :items="flatNodes" - :item-size="28" + :item-size="SIDEBAR_TREE_ROW_HEIGHT" + :buffer="SIDEBAR_TREE_SCROLL_BUFFER" + :prerender="SIDEBAR_TREE_PRERENDER_COUNT" key-field="id" type-field="type" flow-mode diff --git a/src/composables/useFlatTree.ts b/src/composables/useFlatTree.ts index 7bf0e8d34a..72867388e4 100644 --- a/src/composables/useFlatTree.ts +++ b/src/composables/useFlatTree.ts @@ -1,5 +1,9 @@ import type { TreeNode, TreeNodeType } from "@/types/database"; +export const SIDEBAR_TREE_ROW_HEIGHT = 28; +export const SIDEBAR_TREE_SCROLL_BUFFER = SIDEBAR_TREE_ROW_HEIGHT * 48; +export const SIDEBAR_TREE_PRERENDER_COUNT = 48; + export interface FlatTreeNode { node: TreeNode; depth: number; diff --git a/src/lib/tableTree.ts b/src/lib/tableTree.ts new file mode 100644 index 0000000000..d2fede687d --- /dev/null +++ b/src/lib/tableTree.ts @@ -0,0 +1,41 @@ +import type { TableInfo, TreeNode } from "@/types/database"; + +export function buildTableTreeNodes({ + nodeId, + connectionId, + database, + schema, + tables, +}: { + nodeId: string; + connectionId: string; + database: string; + schema?: string; + tables: TableInfo[]; +}): TreeNode[] { + return tables.map((table) => ({ + id: `${nodeId}:${table.name}`, + label: table.name, + type: table.table_type === "VIEW" ? "view" : "table", + connectionId, + database, + schema, + isExpanded: false, + children: [], + })); +} + +export function expandCachedObjectBrowserNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.flatMap((node) => { + if (node.type === "object-browser") return node.hiddenChildren ?? []; + + if (!node.children) return [node]; + + return [ + { + ...node, + children: expandCachedObjectBrowserNodes(node.children), + }, + ]; + }); +} diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 59ee316fba..f7ac91588f 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -23,10 +23,10 @@ import { isTauriRuntime } from "@/lib/tauriRuntime"; import { isSchemaAware, TREE_SCHEMA_TYPES } from "@/lib/databaseCapabilities"; import { buildDatabaseTreeNodes } from "@/lib/databaseTree"; import { buildSqlServerDatabaseTreeNodes, SQLSERVER_DEFAULT_SCHEMA } from "@/lib/sqlServerTree"; +import { buildTableTreeNodes, expandCachedObjectBrowserNodes } from "@/lib/tableTree"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; -const SIDEBAR_TABLE_NODE_LIMIT = 15; type ImportSource = "dbx" | "navicat" | "dbeaver"; interface LoadTreeOptions { @@ -280,9 +280,12 @@ export const useConnectionStore = defineStore("connection", () => { async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string): Promise { const children = await api.loadSchemaCache(cacheKey).catch(() => null); if (!children) return false; + const normalizedChildren = expandCachedObjectBrowserNodes(children); setChildren( node, - node.type === "connection" && node.connectionId ? withSavedSqlRoot(node.connectionId, children, node) : children, + node.type === "connection" && node.connectionId + ? withSavedSqlRoot(node.connectionId, normalizedChildren, node) + : normalizedChildren, ); node.isExpanded = true; return true; @@ -292,24 +295,6 @@ export const useConnectionStore = defineStore("connection", () => { await api.saveSchemaCache(cacheKey, children).catch(() => undefined); } - function buildObjectBrowserNode( - nodeId: string, - connectionId: string, - database: string, - schema: string | undefined, - objectCount: number, - ): TreeNode { - return { - id: `${nodeId}:__object_browser`, - label: "tree.objectBrowser", - type: "object-browser", - connectionId, - database, - schema, - objectCount, - }; - } - function useCachedChildren(node: TreeNode, options?: LoadTreeOptions): boolean { if (options?.force || !loadedTreeNodeChildrenIds.value.has(node.id)) return false; node.isExpanded = true; @@ -683,34 +668,7 @@ export const useConnectionStore = defineStore("connection", () => { api.listSchemas(connectionId, database), api.listTables(connectionId, database, SQLSERVER_DEFAULT_SCHEMA), ]); - let children = buildSqlServerDatabaseTreeNodes(connectionId, database, schemas, defaultSchemaTables); - if (defaultSchemaTables.length > SIDEBAR_TABLE_NODE_LIMIT) { - const previewTables = buildSqlServerDatabaseTreeNodes( - connectionId, - database, - [], - defaultSchemaTables.slice(0, SIDEBAR_TABLE_NODE_LIMIT), - ); - const browserNode = buildObjectBrowserNode( - `${nodeId}:${SQLSERVER_DEFAULT_SCHEMA}`, - connectionId, - database, - SQLSERVER_DEFAULT_SCHEMA, - defaultSchemaTables.length, - ); - const hiddenTables = defaultSchemaTables.slice(SIDEBAR_TABLE_NODE_LIMIT); - browserNode.hiddenChildren = hiddenTables.map((t) => ({ - id: `${nodeId}:${SQLSERVER_DEFAULT_SCHEMA}:${t.name}`, - label: t.name, - type: (t.table_type === "VIEW" ? "view" : "table") as "view" | "table", - connectionId, - database, - schema: SQLSERVER_DEFAULT_SCHEMA, - isExpanded: false, - children: [], - })); - children = [...previewTables, browserNode, ...children.filter((child) => child.type === "schema")]; - } + const children = buildSqlServerDatabaseTreeNodes(connectionId, database, schemas, defaultSchemaTables); setChildren(node, children); await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; @@ -734,32 +692,13 @@ export const useConnectionStore = defineStore("connection", () => { const tables = await api.listTables(connectionId, database, querySchema); const config = getConfig(connectionId); const effectiveSchema = schema || (config?.db_type && isSchemaAware(config.db_type) ? database : undefined); - const visibleTables = tables.slice(0, SIDEBAR_TABLE_NODE_LIMIT); - const children: TreeNode[] = visibleTables.map((t) => ({ - id: `${nodeId}:${t.name}`, - label: t.name, - type: (t.table_type === "VIEW" ? "view" : "table") as "view" | "table", + const children = buildTableTreeNodes({ + nodeId, connectionId, database, schema: effectiveSchema, - isExpanded: false, - children: [], - })); - if (tables.length > SIDEBAR_TABLE_NODE_LIMIT) { - const hiddenTables = tables.slice(SIDEBAR_TABLE_NODE_LIMIT); - const browserNode = buildObjectBrowserNode(nodeId, connectionId, database, effectiveSchema, tables.length); - browserNode.hiddenChildren = hiddenTables.map((t) => ({ - id: `${nodeId}:${t.name}`, - label: t.name, - type: (t.table_type === "VIEW" ? "view" : "table") as "view" | "table", - connectionId, - database, - schema: effectiveSchema, - isExpanded: false, - children: [], - })); - children.push(browserNode); - } + tables, + }); setChildren(node, children); await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; diff --git a/tests/tableTree.test.ts b/tests/tableTree.test.ts new file mode 100644 index 0000000000..01ac60aec7 --- /dev/null +++ b/tests/tableTree.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildTableTreeNodes, expandCachedObjectBrowserNodes } from "../src/lib/tableTree.ts"; +import type { TableInfo } from "../src/types/database.ts"; + +function table(name: string, tableType: "TABLE" | "VIEW" = "TABLE"): TableInfo { + return { name, table_type: tableType }; +} + +test("keeps every table as a sidebar node instead of truncating to object browser", () => { + const tables: TableInfo[] = Array.from({ length: 16 }, (_, index) => table(`table_${index + 1}`)); + + const nodes = buildTableTreeNodes({ + nodeId: "conn:db", + connectionId: "conn", + database: "db", + tables, + }); + + assert.equal(nodes.length, 16); + assert.equal(nodes.at(-1)?.label, "table_16"); + assert.equal( + nodes.some((node) => node.type === "object-browser"), + false, + ); +}); + +test("preserves table and view node types", () => { + const nodes = buildTableTreeNodes({ + nodeId: "conn:db:public", + connectionId: "conn", + database: "db", + schema: "public", + tables: [table("users"), table("user_view", "VIEW")], + }); + + assert.deepEqual( + nodes.map((node) => [node.label, node.type, node.schema]), + [ + ["users", "table", "public"], + ["user_view", "view", "public"], + ], + ); +}); + +test("expands cached object-browser nodes back into regular table nodes", () => { + const nodes = expandCachedObjectBrowserNodes([ + { + id: "conn:db:table_1", + label: "table_1", + type: "table", + connectionId: "conn", + database: "db", + isExpanded: false, + children: [], + }, + { + id: "conn:db:__object_browser", + label: "tree.objectBrowser", + type: "object-browser", + connectionId: "conn", + database: "db", + hiddenChildren: [ + { + id: "conn:db:table_16", + label: "table_16", + type: "table", + connectionId: "conn", + database: "db", + isExpanded: false, + children: [], + }, + ], + }, + ]); + + assert.deepEqual( + nodes.map((node) => [node.label, node.type]), + [ + ["table_1", "table"], + ["table_16", "table"], + ], + ); +}); diff --git a/tests/useFlatTree.test.ts b/tests/useFlatTree.test.ts index 76919265cc..169a9fb10f 100644 --- a/tests/useFlatTree.test.ts +++ b/tests/useFlatTree.test.ts @@ -1,6 +1,12 @@ import { strict as assert } from "node:assert"; import test from "node:test"; -import { flattenTree, shouldVirtualizeFlatTree } from "../src/composables/useFlatTree.ts"; +import { + SIDEBAR_TREE_ROW_HEIGHT, + SIDEBAR_TREE_PRERENDER_COUNT, + SIDEBAR_TREE_SCROLL_BUFFER, + flattenTree, + shouldVirtualizeFlatTree, +} from "../src/composables/useFlatTree.ts"; import type { TreeNode } from "../src/types/database.ts"; test("flattenTree preserves depth and node type for virtualized sidebar rows", () => { @@ -40,3 +46,12 @@ test("shouldVirtualizeFlatTree only virtualizes very large sidebar trees", () => assert.equal(shouldVirtualizeFlatTree(99), false); assert.equal(shouldVirtualizeFlatTree(100), true); }); + +test("sidebar virtual tree keeps enough buffered rows for fast scrolling", () => { + assert.equal(SIDEBAR_TREE_ROW_HEIGHT, 28); + assert.ok(SIDEBAR_TREE_SCROLL_BUFFER >= SIDEBAR_TREE_ROW_HEIGHT * 40); +}); + +test("sidebar virtual tree prerenders enough rows for the first frame", () => { + assert.ok(SIDEBAR_TREE_PRERENDER_COUNT >= 40); +});