From 0aa18257e79b443f64056a04839e168c41b611a7 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 25 Aug 2026 10:33:13 +0800 Subject: [PATCH] fix(sidebar): restore database-first sticky headers --- .../src/components/sidebar/ConnectionTree.vue | 17 +++--- .../src/components/sidebar/TreeItem.vue | 4 +- .../app-tests/sidebarTreeAffordances.test.ts | 11 ++++ packages/app-tests/useFlatTree.test.ts | 57 ++++++++++++++++++- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index d98a91a268..7dfa0622e3 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -785,15 +785,14 @@ function readExpandedSidebarSchemas(): Array<{ id: string; label: string }> { return expanded; } -// The plain (non-virtualized) renderer keeps the sticky database/schema header -// via native CSS position: sticky on the container rows, mirroring the overlay -// the virtual branch renders. Only container rows stick; children scroll under -// them until the next container takes over. -function isPlainStickyContainerNode(node: TreeNode): boolean { +// The plain (non-virtualized) renderer uses the same container selection as the +// virtual sticky overlay: database containers take precedence, while schema +// containers stick only in trees without a database-level container. +function isPlainStickyContainerNode(index: number): boolean { // Mirror the virtual branch's sticky overlay: both suppress sticky headers // while a search filter is active so filtered rows don't pin at the top. if (isTreeSearchFiltering.value) return false; - return DATABASE_LEVEL_TYPES.has(node.type) || SCHEMA_LEVEL_TYPES.has(node.type); + return flatTreeIndex.value.stickyContainerIndexByIndex[index] === index; } const sidebarLayoutMonitor = createSidebarLayoutMonitor({ @@ -2506,7 +2505,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid /> -
+
-
+
{ assert.match(treeItem, /@contextmenu="onTreeItemContextMenu"/); }); +test("plain tree sticky headers position the full row wrapper without a divider", () => { + assert.match(treeItem, /
/); + assert.doesNotMatch(treeItem, /'tree-item-highlight': highlighted,\s*'sidebar-tree-item--sticky': stickyHeader/); + assert.match(treeItem, /\.sidebar-tree-item--sticky\s*\{[\s\S]*?position:\s*sticky;/); + assert.doesNotMatch(treeItem, /\.sidebar-tree-item--sticky\s*\{[^}]*border-bottom:/); + assert.doesNotMatch(connectionTree, /sticky-database-header[^\n]*border-b/); + assert.match(connectionTree, /v-for="\(item, index\) in flatNodes"/); + assert.match(connectionTree, /:sticky-header="isPlainStickyContainerNode\(index\)"/); + assert.match(connectionTree, /return flatTreeIndex\.value\.stickyContainerIndexByIndex\[index\] === index;/); +}); + test("complex tree changes retain the full rebuild fallback", () => { assert.match(connectionTree, /const filteredNodes = computed/); assert.match(connectionTree, /filterSidebarTree\(/); diff --git a/packages/app-tests/useFlatTree.test.ts b/packages/app-tests/useFlatTree.test.ts index 6b945046ff..e9b2cd78ab 100644 --- a/packages/app-tests/useFlatTree.test.ts +++ b/packages/app-tests/useFlatTree.test.ts @@ -1,7 +1,7 @@ import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; import { test } from "vitest"; -import { SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree } from "../../apps/desktop/src/composables/useFlatTree.ts"; +import { createFlatTreeIndex, SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree } from "../../apps/desktop/src/composables/useFlatTree.ts"; import type { TreeNode } from "../../apps/desktop/src/types/database.ts"; const connectionTreeSource = readFileSync(new URL("../../apps/desktop/src/components/sidebar/ConnectionTree.vue", import.meta.url), "utf8"); @@ -54,6 +54,61 @@ test("connection groups use per-node pool types to avoid recycled row state", () assert.equal(flat[2].poolType, "connection"); }); +test("sticky containers prefer databases and fall back to schemas", () => { + const flat = flattenTree([ + { + id: "with-database", + label: "With database", + type: "connection", + isExpanded: true, + children: [ + { + id: "database", + label: "Database", + type: "database", + isExpanded: true, + children: [ + { + id: "database:schema", + label: "Schema", + type: "schema", + isExpanded: true, + children: [{ id: "database:schema:table", label: "Table", type: "table" }], + }, + ], + }, + ], + }, + { + id: "schema-only", + label: "Schema only", + type: "connection", + isExpanded: true, + children: [ + { + id: "schema-only:schema", + label: "Schema", + type: "schema", + isExpanded: true, + children: [{ id: "schema-only:schema:table", label: "Table", type: "table" }], + }, + ], + }, + ]); + const index = createFlatTreeIndex(flat, { + isSelectable: () => true, + isBoundary: (type) => type === "connection" || type === "connection-group", + isDatabaseContainer: (type) => type === "database", + isSchemaContainer: (type) => type === "schema", + }); + + assert.equal(index.stickyContainerIndexByIndex[1], 1); + assert.equal(index.stickyContainerIndexByIndex[2], 1); + assert.equal(index.stickyContainerIndexByIndex[3], 1); + assert.equal(index.stickyContainerIndexByIndex[5], 5); + assert.equal(index.stickyContainerIndexByIndex[6], 5); +}); + test("shouldVirtualizeFlatTree keeps small/medium trees on the plain renderer", () => { assert.equal(shouldVirtualizeFlatTree(0), false); assert.equal(shouldVirtualizeFlatTree(1), false);