feat(sidebar): pin database row while scrolling the tree
This commit is contained in:
1 parent
5522842b19
commit
41ab2fae2a
1 file changed
+63
@@ -97,6 +97,12 @@ const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
|
|||||||
view: ["view"],
|
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<TreeNodeType>(SEARCH_SCOPE_TO_NODE_TYPES.database);
|
||||||
|
|
||||||
const searchScopeOptions = computed(() => {
|
const searchScopeOptions = computed(() => {
|
||||||
return [
|
return [
|
||||||
{ scope: "connection", label: t("sidebar.searchScopeConnection"), icon: Server },
|
{ scope: "connection", label: t("sidebar.searchScopeConnection"), icon: Server },
|
||||||
@@ -178,6 +184,56 @@ const visibleNodeIndexById = computed(() => {
|
|||||||
});
|
});
|
||||||
const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length));
|
const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length));
|
||||||
const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId));
|
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 <TreeItem>, 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<FlatTreeNode | null>(() => {
|
||||||
|
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"));
|
const sidebarTreeOverflowClass = computed(() => (settingsStore.editorSettings.sidebarAllowHorizontalScroll ? "overflow-x-auto sidebar-tree-horizontal-scroll" : "overflow-x-hidden"));
|
||||||
|
|
||||||
provide(sidebarTreeContextKey, {
|
provide(sidebarTreeContextKey, {
|
||||||
@@ -601,6 +657,9 @@ defineExpose({ focusSearch, createNewGroup });
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="stickyNode" class="sticky-database-header relative z-[5] border-b border-border/60">
|
||||||
|
<TreeItem :node="stickyNode.node" :depth="stickyNode.depth" :drag-disabled="true" @search-toggle="onSearchToggle" />
|
||||||
|
</div>
|
||||||
<RecycleScroller
|
<RecycleScroller
|
||||||
v-if="flatNodes.length > 0 && useVirtualTree"
|
v-if="flatNodes.length > 0 && useVirtualTree"
|
||||||
ref="treeScrollerRef"
|
ref="treeScrollerRef"
|
||||||
@@ -640,6 +699,10 @@ defineExpose({ focusSearch, createNewGroup });
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.sticky-database-header {
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
.connection-tree-scroller {
|
.connection-tree-scroller {
|
||||||
will-change: scroll-position;
|
will-change: scroll-position;
|
||||||
contain: content;
|
contain: content;
|
||||||
|
|||||||
Reference in new issue
Block a user