From 57b350eb497169be34927cdf4a5a70149c06a6d0 Mon Sep 17 00:00:00 2001 From: GIWTO <40267153+GIWTO@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:58:15 +0800 Subject: [PATCH] fix(sidebar): Dameng tree blank band, admin node duplication and schema auto-expand --- .../src/components/sidebar/ConnectionTree.vue | 153 ++- .../src/components/sidebar/TreeItem.vue | 18 + apps/desktop/src/composables/useFlatTree.ts | 21 +- .../sidebar/sidebarLayoutMonitor.spec.ts | 163 +++ .../src/lib/sidebar/sidebarLayoutMonitor.ts | 929 ++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 46 +- .../app-tests/sidebarTreeAffordances.test.ts | 6 +- packages/app-tests/useFlatTree.test.ts | 9 +- 8 files changed, 1330 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/sidebar/sidebarLayoutMonitor.spec.ts create mode 100644 apps/desktop/src/lib/sidebar/sidebarLayoutMonitor.ts diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 06c2c07515..d98a91a268 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -48,7 +48,7 @@ import { type SidebarNodeScrollAlign, } from "@/lib/sidebar/sidebarActiveTabTarget"; import { findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate, type QueryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget"; -import { createFlatTreeIndex, flatTreeRowsChanged, SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree"; +import { createFlatTreeIndex, flatTreeRowsChanged, SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, SIDEBAR_TREE_VIRTUALIZE_HYSTERESIS, SIDEBAR_TREE_VIRTUALIZE_THRESHOLD, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHostInstance } from "@/lib/sidebar/sidebarTreeRuntime"; import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry"; @@ -81,6 +81,7 @@ import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay"; import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNaturalContentWidth, sidebarTreeNodeComment, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout"; import { formatSidebarObjectStorage, sidebarTableStorageScopes, supportsSidebarTableStorage } from "@/lib/sidebar/sidebarDatabaseStorage"; import { sidebarScrollbarGeometry as calculateSidebarScrollbarGeometry } from "@/lib/sidebar/sidebarScrollbar"; +import { createSidebarLayoutMonitor, type SidebarExpandedConnectionInfo } from "@/lib/sidebar/sidebarLayoutMonitor"; import { disconnectSidebarConnections } from "@/lib/sidebar/sidebarConnectionDisconnect"; import { compileSearchRegex } from "@/lib/common/searchPattern"; @@ -103,6 +104,7 @@ const sidebarRootStyle = computed>(() => ({ fontSize: `${ const pointerInsideTree = ref(false); const treeScrollerRef = ref | null>(null); const plainTreeScrollerRef = ref(null); +const treeScrollShellRef = ref(null); const sidebarScrollbarTrackRef = ref(null); const sidebarHorizontalScrollbarTrackRef = ref(null); const sidebarContextMenuRef = ref<{ close: () => void } | null>(null); @@ -745,6 +747,68 @@ const flatNodes = computed(() => }), ); +// Debug instrumentation for the "expanded tree leaves the scroll shell stuck +// (e.g. Dameng connection expanded and the viewport froze near half height)" +// class of layout bugs. Feeds the layout monitor with expand events and +// tree-size changes so its anomaly reports can describe the transition. +function readExpandedSidebarConnections(): SidebarExpandedConnectionInfo[] { + const expanded: SidebarExpandedConnectionInfo[] = []; + for (const node of filteredNodes.value) { + if (node.type !== "connection" && node.type !== "connection-group") continue; + if (!node.isExpanded) continue; + let descendantCount = 0; + const stack = [...(node.children ?? [])]; + while (stack.length > 0) { + const child = stack.pop() as TreeNode; + descendantCount += 1; + if (child.children) stack.push(...child.children); + } + expanded.push({ id: node.id, label: node.label, type: node.type, descendantCount }); + } + return expanded; +} + +// Which schema nodes are expanded right now — lets the layout monitor tell a +// user-initiated expand (an expand-toggle event precedes it) from a +// programmatic one (the schema shows up here without one). +function readExpandedSidebarSchemas(): Array<{ id: string; label: string }> { + const expanded: Array<{ id: string; label: string }> = []; + for (const connection of filteredNodes.value) { + if (connection.type !== "connection" && connection.type !== "connection-group") continue; + const stack = [...(connection.children ?? [])]; + while (stack.length > 0) { + const child = stack.pop() as TreeNode; + if (child.type === "schema" && child.isExpanded) expanded.push({ id: child.id, label: child.label }); + if (child.children) stack.push(...child.children); + } + } + 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 { + // 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); +} + +const sidebarLayoutMonitor = createSidebarLayoutMonitor({ + readContext: () => ({ + flatNodeCount: flatNodes.value.length, + useVirtualTree: useVirtualTree.value, + virtualItemSize: SIDEBAR_TREE_ROW_HEIGHT, + scrollerEl: currentTreeScroller(), + shellEl: treeScrollShellRef.value, + rootEl: rootRef.value ?? null, + virtualScroller: (treeScrollerRef.value as unknown as Record | null) ?? null, + expandedConnections: readExpandedSidebarConnections(), + }), +}); + watch(flatNodes, refreshPendingInvalidatedTableSearchScopes, { flush: "post" }); const sidebarCommentLabelWidths = shallowRef(new Map()); @@ -876,7 +940,38 @@ const flatTreeIndex = computed(() => const visibleNodes = computed(() => flatTreeIndex.value.visibleNodes); const selectableVisibleNodes = computed(() => flatTreeIndex.value.selectableVisibleNodes); const selectableVisibleNodeIndexById = computed(() => flatTreeIndex.value.selectableVisibleNodeIndexById); -const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length)); +// Virtualization with hysteresis: switching between the plain and virtual +// renderers remounts the tree (scroll reset), so once virtualized stay virtual +// until the tree drops well below the threshold — otherwise search/filter +// oscillation around the boundary would thrash between the two branches. +const treeVirtualizationArmed = ref(false); +watch( + () => flatNodes.value.length, + (count) => { + if (treeVirtualizationArmed.value) { + if (count < SIDEBAR_TREE_VIRTUALIZE_THRESHOLD - SIDEBAR_TREE_VIRTUALIZE_HYSTERESIS) { + treeVirtualizationArmed.value = false; + } + } else if (shouldVirtualizeFlatTree(count)) { + treeVirtualizationArmed.value = true; + } + }, + { immediate: true }, +); +const useVirtualTree = computed(() => treeVirtualizationArmed.value); + +// The plain and virtual branches mount separate scroller elements; keep the +// scroll position when the renderer flips so crossing the threshold does not +// jump the user back to the top. +watch(useVirtualTree, (virtual, wasVirtual) => { + const previous = (wasVirtual ? treeScrollerRef.value?.$el : plainTreeScrollerRef.value) as HTMLElement | undefined; + const savedScrollTop = previous?.scrollTop ?? 0; + if (!savedScrollTop) return; + void nextTick(() => { + const next = (virtual ? treeScrollerRef.value?.$el : plainTreeScrollerRef.value) as HTMLElement | undefined; + if (next) next.scrollTop = savedScrollTop; + }); +}); const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId)); const hasTreeMultiSelection = computed(() => store.connectionMultiSelectActive || store.selectedTreeNodeIds.length > 1); @@ -1014,6 +1109,26 @@ const stickyHeaderStyle = computed(() => { // Reset tracking when the tree rebuilds (connect/disconnect/collapse) so a // stale scrollTop doesn't keep the overlay mounted after a structural change. watch(flatNodes, (nodes, previousNodes) => { + // The diagnostic events walk the whole expanded tree; only do that work when + // the layout monitor is actually armed (dev default, disabled in production). + if (sidebarLayoutMonitor.isEnabled()) { + const previousCount = previousNodes?.length ?? 0; + if (nodes.length !== previousCount) { + const previousIds = new Set((previousNodes ?? []).map((entry) => entry.id)); + const added = nodes + .filter((entry) => !previousIds.has(entry.id)) + .slice(0, 5) + .map((entry) => ({ type: entry.node.type, label: entry.node.label })); + sidebarLayoutMonitor.recordEvent({ + type: "tree-change", + prevCount: previousCount, + count: nodes.length, + expandedConnections: readExpandedSidebarConnections(), + added, + expandedSchemas: readExpandedSidebarSchemas(), + }); + } + } const contextMenuTarget = sidebarContextMenuTarget.value; if (contextMenuTarget) { const visibleContextMenuTarget = nodes.find(({ node }) => matchesSidebarActionTarget(node, contextMenuTarget))?.node; @@ -1035,9 +1150,10 @@ watch(flatNodes, (nodes, previousNodes) => { // Reading scrollHeight forces that layout (applying the clamp), then one // explicit pool refresh keeps the rendered window aligned. if (!flatTreeRowsChanged(nodes, previousNodes)) return; + if (!useVirtualTree.value) return; void nextTick(() => { const scroller = treeScrollerRef.value; - if (!scroller || !useVirtualTree.value) return; + if (!scroller) return; const el = scroller.$el as HTMLElement | undefined; if (!el) return; const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight); @@ -1649,6 +1765,13 @@ function onSearchToggle(node: TreeNode) { function onNodeToggled(node: TreeNode, expanded: boolean) { if (isTreeSearchFiltering.value) return; + sidebarLayoutMonitor.recordEvent({ + type: "expand-toggle", + nodeId: node.id, + label: node.label, + nodeType: node.type, + expanded, + }); syncSidebarTreeNodeExpansion(store.treeNodes, node, expanded); } @@ -2181,10 +2304,12 @@ function requestSelectedSidebarPaste(): boolean { } onMounted(() => { + sidebarLayoutMonitor.start(); window.addEventListener("keydown", onWindowKeydown); }); onUnmounted(() => { + sidebarLayoutMonitor.dispose(); sidebarTreeRuntime.dispose(); sidebarActionGeneration += 1; sidebarContextMenuTarget.value = null; @@ -2344,7 +2469,13 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid -
+
+