From 41ab2fae2a8268eb16c7441b2ff9083dbafd451f Mon Sep 17 00:00:00 2001 From: haipengno1 Date: Thu, 25 Jun 2026 00:20:32 +0800 Subject: [PATCH] feat(sidebar): pin database row while scrolling the tree --- .../src/components/sidebar/ConnectionTree.vue | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index afc199df4..e249ea9ab 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -97,6 +97,12 @@ const SEARCH_SCOPE_TO_NODE_TYPES: Record = { view: ["view"], }; +// Database-level container types. When browsing a large number of children +// under one of these (e.g. hundreds of tables) and scrolling down, the row is +// kept pinned at the top of the tree so the active database stays visible and +// can be collapsed with one click. Mirrors the `database` search scope above. +const DATABASE_LEVEL_TYPES = new Set(SEARCH_SCOPE_TO_NODE_TYPES.database); + const searchScopeOptions = computed(() => { return [ { scope: "connection", label: t("sidebar.searchScopeConnection"), icon: Server }, @@ -178,6 +184,56 @@ const visibleNodeIndexById = computed(() => { }); const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length)); const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId)); + +// --- Sticky database header --- +// RecycleScroller positions each row absolutely, so CSS `position: sticky` on +// a database row can't work. Instead we overlay a pinned row from this parent +// component, tracking scroll offset to find the topmost visible database-level +// ancestor. The overlay reuses , so collapse/expand comes for free. +const stickyScrollTop = ref(0); + +function onTreeScroll() { + const scroller = (treeScrollerRef.value?.$el as HTMLElement | undefined) ?? null; + if (scroller) stickyScrollTop.value = scroller.scrollTop; +} + +// RecycleScroller only emits scrollStart/scrollEnd, not continuous scroll, so +// attach a native passive listener on its root element once it mounts. +watch( + treeScrollerRef, + (scroller, _old, onCleanup) => { + const el = (scroller?.$el as HTMLElement | undefined) ?? null; + if (!el) return; + el.addEventListener("scroll", onTreeScroll, { passive: true }); + onCleanup(() => el.removeEventListener("scroll", onTreeScroll)); + }, + { flush: "post" }, +); + +const stickyNode = computed(() => { + if (!useVirtualTree.value || isFiltering.value) return null; + const nodes = flatNodes.value; + const len = nodes.length; + if (len === 0) return null; + + const topIndex = Math.min(Math.floor(stickyScrollTop.value / SIDEBAR_TREE_ROW_HEIGHT), len - 1); + // Walk UP from the topmost visible row to the nearest database-level ancestor. + // If the topmost row is itself a database node (it hasn't scrolled past the + // viewport yet), return null so the overlay doesn't duplicate the real row. + for (let i = topIndex; i >= 0; i--) { + const item = nodes[i]; + if (!DATABASE_LEVEL_TYPES.has(item.type)) continue; + return i === topIndex ? null : item; + } + return null; +}); + +// 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, () => { + stickyScrollTop.value = 0; +}); + const sidebarTreeOverflowClass = computed(() => (settingsStore.editorSettings.sidebarAllowHorizontalScroll ? "overflow-x-auto sidebar-tree-horizontal-scroll" : "overflow-x-hidden")); provide(sidebarTreeContextKey, { @@ -601,6 +657,9 @@ defineExpose({ focusSearch, createNewGroup }); /> +
+ +