From 07482557e64c8b1ae30ace4f57e6fb895a71a099 Mon Sep 17 00:00:00 2001 From: auth0 Date: Wed, 8 Jul 2026 20:21:13 +0800 Subject: [PATCH] fix(sidebar): locate active table reliably --- .../src/components/sidebar/ConnectionTree.vue | 34 +++- .../src/lib/sidebar/sidebarActiveTabTarget.ts | 26 ++- .../app-tests/sidebarActiveTabTarget.test.ts | 175 +++++++++++++++++- 3 files changed, 221 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 1666ff643f..3f8a6edbd2 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -15,7 +15,7 @@ import { connectionPasteTargetGroupId, selectedConnectionClipboardNodes, selecte import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch"; import { usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; -import { activeTabSidebarTarget, findSidebarNodeForActiveTab, findSidebarNodeForTarget, findNodePathForTarget, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection, type ActiveTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget"; +import { activeTabSidebarTarget, findSidebarNodeForActiveTab, findSidebarNodeForTarget, findNodePathForTarget, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection, type ActiveTabSidebarTarget, type SidebarNodeScrollAlign } from "@/lib/sidebar/sidebarActiveTabTarget"; import { findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate, type QueryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget"; import { SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; @@ -534,13 +534,33 @@ const pendingRenameGroupId = ref(null); const highlightedNodeId = ref(null); let highlightTimer: number | undefined; +// 等待虚拟列表渲染后再高亮。 +function waitForSidebarRenderFrame(): Promise { + return new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()); + }); +} + +// 重新触发定位高亮,支持连续定位同一节点。 +async function flashSidebarNode(nodeId: string) { + window.clearTimeout(highlightTimer); + highlightedNodeId.value = null; + await nextTick(); + await waitForSidebarRenderFrame(); + + highlightedNodeId.value = nodeId; + highlightTimer = window.setTimeout(() => { + if (highlightedNodeId.value === nodeId) highlightedNodeId.value = null; + }, 1800); +} + function topOcclusionHeightForSidebarNode(nodeId: string): number { const sticky = stickyNode.value; if (!useVirtualTree.value || !sticky || sticky.id === nodeId) return 0; return SIDEBAR_TREE_ROW_HEIGHT; } -async function scrollToSidebarNode(nodeId: string) { +async function scrollToSidebarNode(nodeId: string, options?: { align?: SidebarNodeScrollAlign }) { await nextTick(); const index = flatNodes.value.findIndex((item) => item.id === nodeId); @@ -552,6 +572,7 @@ async function scrollToSidebarNode(nodeId: string) { currentScrollTop: scroller.scrollTop, viewportHeight: scroller.clientHeight, topOcclusionHeight: topOcclusionHeightForSidebarNode(nodeId), + ...(options?.align ? { align: options.align } : {}), }); if (nextScrollTop !== scroller.scrollTop) { scroller.scrollTop = nextScrollTop; @@ -661,13 +682,8 @@ async function locateActiveTabInSidebar() { store.treeSelectionAnchorId = match.id; await nextTick(); - window.clearTimeout(highlightTimer); - highlightedNodeId.value = match.id; - highlightTimer = window.setTimeout(() => { - highlightedNodeId.value = null; - }, 1800); - - await scrollToSidebarNode(match.id); + await scrollToSidebarNode(match.id, { align: "smart" }); + await flashSidebarNode(match.id); } function tableTargetFromCandidate(candidate: QueryCursorTableCandidate): ActiveTabSidebarTarget { diff --git a/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts b/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts index 283eb1328f..9ae7c708be 100644 --- a/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts +++ b/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts @@ -143,7 +143,13 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active function schemaMatches(node: TreeNode, schema: string | undefined): boolean { if (!schema) return true; - return (node.schema || "") === schema; + + if ((node.schema || "") === schema) return true; + // Some database engines (for example MySQL in database-object tree mode) do + // not store a separate schema on table nodes. In that case the database name + // is the effective schema, so a target schema equal to the node database + // should still resolve to the loaded table node. + return !node.schema && (node.database || "") === schema; } export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): boolean { @@ -207,16 +213,28 @@ export function shouldScrollActiveSidebarSelection(options: { activeTabId: strin return options.activeTabId !== options.previousActiveTabId || (options.autoSelectEnabled && options.previousAutoSelectEnabled === false); } -export function scrollTopForSidebarNode(options: { index: number; currentScrollTop: number; viewportHeight: number; rowHeight?: number; topOcclusionHeight?: number }): number { +// nearest is used for passive auto-selection; smart keeps context for explicit locate actions. +export type SidebarNodeScrollAlign = "nearest" | "top" | "smart"; + +export function scrollTopForSidebarNode(options: { index: number; currentScrollTop: number; viewportHeight: number; rowHeight?: number; topOcclusionHeight?: number; align?: SidebarNodeScrollAlign }): number { const rowHeight = options.rowHeight ?? SIDEBAR_TREE_ROW_HEIGHT; if (options.index < 0 || options.viewportHeight <= 0) return options.currentScrollTop; const rowTop = options.index * rowHeight; const rowBottom = rowTop + rowHeight; - const viewportTop = options.currentScrollTop + (options.topOcclusionHeight ?? 0); + const topOcclusionHeight = options.topOcclusionHeight ?? 0; + if (options.align === "top") return Math.max(0, rowTop - topOcclusionHeight); + if (options.align === "smart") { + // Similar to IDE Locate: place the target around the upper third of the viewport. + const availableViewportHeight = Math.max(rowHeight, options.viewportHeight - topOcclusionHeight); + const smartOffset = Math.max(0, (availableViewportHeight - rowHeight) / 3); + return Math.max(0, Math.round(rowTop - topOcclusionHeight - smartOffset)); + } + + const viewportTop = options.currentScrollTop + topOcclusionHeight; const viewportBottom = options.currentScrollTop + options.viewportHeight; - if (rowTop < viewportTop) return Math.max(0, rowTop - (options.topOcclusionHeight ?? 0)); + if (rowTop < viewportTop) return Math.max(0, rowTop - topOcclusionHeight); if (rowBottom > viewportBottom) return Math.max(0, rowBottom - options.viewportHeight); return options.currentScrollTop; } diff --git a/packages/app-tests/sidebarActiveTabTarget.test.ts b/packages/app-tests/sidebarActiveTabTarget.test.ts index d72708934e..c67cef4b6b 100644 --- a/packages/app-tests/sidebarActiveTabTarget.test.ts +++ b/packages/app-tests/sidebarActiveTabTarget.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { activeTabSidebarTarget, findSidebarNodeForActiveTab, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection } from "../../apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts"; +import { activeTabSidebarTarget, findNodePathForTarget, findSidebarNodeForActiveTab, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection } from "../../apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts"; import type { FlatTreeNode } from "../../apps/desktop/src/composables/useFlatTree.ts"; import type { QueryTab, TreeNode } from "../../apps/desktop/src/types/database.ts"; @@ -8,6 +8,172 @@ function flat(node: TreeNode, depth = 0): FlatTreeNode { return { id: node.id, node, depth, type: node.type }; } +test("findNodePathForTarget handles loaded, unloaded, and MySQL schema fallback trees", () => { + const cases = [ + { + name: "nested table path", + tree: [ + { + id: "conn-1", + label: "mysql.example.test", + type: "connection", + connectionId: "conn-1", + isExpanded: true, + children: [ + { + id: "conn-1:__user_admin", + label: "tree.userAdmin", + type: "user-admin", + connectionId: "conn-1", + database: "", + isExpanded: false, + }, + { + id: "conn-1:app", + label: "app", + type: "database", + connectionId: "conn-1", + database: "app", + isExpanded: true, + children: [ + { + id: "conn-1:app:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: "conn-1", + database: "app", + isExpanded: true, + children: [ + { + id: "conn-1:app:__tables:users", + label: "users", + type: "table", + connectionId: "conn-1", + database: "app", + isExpanded: false, + children: [], + pinned: false, + }, + ], + pinned: false, + }, + ], + pinned: false, + }, + ], + pinned: false, + }, + ], + target: { + type: "table", + connectionId: "conn-1", + database: "app", + tableName: "users", + }, + expectedPath: ["conn-1", "conn-1:app", "conn-1:app:__tables", "conn-1:app:__tables:users"], + }, + { + name: "unloaded connection tree", + tree: [ + { + id: "mysql-conn-1", + label: "mysql.example.test", + type: "connection", + connectionId: "mysql-conn-1", + isExpanded: false, + children: [ + { + id: "mysql-conn-1:__user_admin", + label: "tree.userAdmin", + type: "user-admin", + connectionId: "mysql-conn-1", + database: "", + isExpanded: false, + }, + ], + pinned: false, + }, + ], + target: { + type: "query-context", + connectionId: "mysql-conn-1", + database: "app_prd", + }, + expectedPath: null, + }, + { + name: "MySQL schema fallback to database", + tree: [ + { + id: "mysql-conn-1", + label: "mysql.example.test", + type: "connection", + connectionId: "mysql-conn-1", + isExpanded: true, + children: [ + { + id: "mysql-conn-1:app_dev", + label: "app_dev", + type: "database", + connectionId: "mysql-conn-1", + database: "app_dev", + isExpanded: true, + children: [ + { + id: "mysql-conn-1:app_dev:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: "mysql-conn-1", + database: "app_dev", + isExpanded: true, + children: [ + { + id: "mysql-conn-1:app_dev:__tables:enum_info", + label: "enum_info", + type: "table", + tableType: "BASE TABLE", + connectionId: "mysql-conn-1", + database: "app_dev", + isExpanded: false, + children: [], + pinned: false, + }, + ], + pinned: false, + }, + ], + pinned: false, + }, + ], + }, + ], + target: { + type: "table", + connectionId: "mysql-conn-1", + database: "app_dev", + schema: "app_dev", + tableName: "enum_info", + }, + expectedPath: [ + "mysql-conn-1", + "mysql-conn-1:app_dev", + "mysql-conn-1:app_dev:__tables", + "mysql-conn-1:app_dev:__tables:enum_info", + ], + }, + ] satisfies Array<{ + name: string; + tree: TreeNode[]; + target: Parameters[0]; + expectedPath: string[] | null; + }>; + + for (const item of cases) { + const path = findNodePathForTarget(item.target, item.tree); + assert.deepEqual(path?.map((node) => node.id) ?? null, item.expectedPath, item.name); + } +}); + test("data tabs target the matching visible table or view node", () => { const tab: QueryTab = { id: "tab-1", @@ -260,6 +426,13 @@ test("sidebar node scrolling keeps visible rows in place and reveals hidden rows assert.equal(scrollTopForSidebarNode({ index: 11, currentScrollTop: 300, viewportHeight: 140, topOcclusionHeight: 28 }), 280); }); +test("sidebar node scrolling supports top and smart locate alignment", () => { + assert.equal(scrollTopForSidebarNode({ index: 20, currentScrollTop: 0, viewportHeight: 140, align: "top" }), 560); + assert.equal(scrollTopForSidebarNode({ index: 20, currentScrollTop: 0, viewportHeight: 140, align: "smart" }), 523); + assert.equal(scrollTopForSidebarNode({ index: 11, currentScrollTop: 300, viewportHeight: 140, topOcclusionHeight: 28, align: "smart" }), 252); + assert.equal(scrollTopForSidebarNode({ index: 0, currentScrollTop: 300, viewportHeight: 140, topOcclusionHeight: 28, align: "smart" }), 0); +}); + test("active sidebar selection only scrolls on tab or setting changes", () => { assert.equal( shouldScrollActiveSidebarSelection({