diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 499461acb9..04f272a2c0 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -955,7 +955,7 @@ 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) => { +watch(flatNodes, (nodes, previousNodes) => { const contextMenuTarget = sidebarContextMenuTarget.value; if (contextMenuTarget) { const visibleContextMenuTarget = nodes.find(({ node }) => matchesSidebarActionTarget(node, contextMenuTarget))?.node; @@ -967,6 +967,25 @@ watch(flatNodes, (nodes) => { } stickyScrollTop.value = 0; void nextTick(scheduleSidebarScrollMetricsUpdate); + // After a structural change (list grew/shrunk, e.g. a Dameng connection + // expands or collapses) reconcile the virtual scroller with the browser's + // scroll position. The recycle pool is rebuilt from the live scrollTop, but + // scrollTop is only clamped on the next layout, so right after a shrink the + // pool can still target a window beyond the new content — the viewport then + // lands inside the end spacer and shows a blank region until the next scroll + // event. Reading scrollHeight forces that layout (applying the clamp), then + // one explicit pool refresh keeps the rendered window aligned. Only needed + // when the item count changed; reorders are already rebuilt by the library. + if (nodes.length === (previousNodes?.length ?? -1)) return; + void nextTick(() => { + const scroller = treeScrollerRef.value; + if (!scroller || !useVirtualTree.value) return; + const el = scroller.$el as HTMLElement | undefined; + if (!el) return; + const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight); + if (el.scrollTop > maxScrollTop) el.scrollTop = maxScrollTop; + scroller.updateVisibleItems(true); + }); }); const sidebarTreeOverflowClass = computed(() => (settingsStore.editorSettings.sidebarAllowHorizontalScroll ? "overflow-x-auto sidebar-tree-horizontal-scroll" : "overflow-x-hidden")); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 05f2f6cc48..331039e0fd 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -1328,7 +1328,7 @@ function onKeydown(event: KeyboardEvent) {
{ @@ -484,7 +488,10 @@ export function mergeTableTreePageChildren(currentChildren: TreeNode[], pageChil const addNode = (node: TreeNode) => { if (node.type !== "table") { - roots.push(node); + if (!rootNodeIds.has(node.id)) { + roots.push(node); + rootNodeIds.add(node.id); + } return; } diff --git a/packages/app-tests/tableTree.test.ts b/packages/app-tests/tableTree.test.ts index 09853af4c1..58e2d122c0 100644 --- a/packages/app-tests/tableTree.test.ts +++ b/packages/app-tests/tableTree.test.ts @@ -13,6 +13,16 @@ function table(name: string, parent?: string): TableInfo { }; } +function view(name: string): TableInfo { + return { + name, + table_type: "VIEW", + comment: null, + parent_schema: null, + parent_name: null, + }; +} + function object(name: string, parent?: string): ObjectInfo { return { name, @@ -105,6 +115,36 @@ test("mergeTableTreePageChildren attaches later page partitions to loaded parent ); }); +test("mergeTableTreePageChildren dedupes non-table nodes repeated across pages", () => { + // Simple-display pagination: a later page can re-return an object the + // current children already hold (Dameng agent falls back between metadata + // queries, so page boundaries drift). Non-table nodes (views) must not be + // inserted twice — duplicate ids break the sidebar scroller's key-field. + const firstPage = buildTableTreeNodes({ + nodeId: "conn:app:public", + connectionId: "conn", + database: "app", + schema: "public", + tables: [table("orders"), view("orders_v")], + }); + const driftedPage = buildTableTreeNodes({ + nodeId: "conn:app:public", + connectionId: "conn", + database: "app", + schema: "public", + tables: [view("orders_v"), table("payments")], + }); + + const merged = mergeTableTreePageChildren(firstPage, driftedPage, "conn", "app"); + const ids = merged.map((node) => node.id); + assert.equal(new Set(ids).size, ids.length, "merged children must not contain duplicate ids"); + assert.equal(merged.filter((node) => node.type === "view").length, 1, "the repeated view must appear once"); + assert.deepEqual( + merged.map((node) => node.label).sort(), + ["orders", "orders_v", "payments"].sort(), + ); +}); + test("buildTableTreeNodes keeps sidebar tables in natural name order", () => { const nodes = buildTableTreeNodes({ nodeId: "conn:app:public",