From 0b49c5a76148d310bd9c005a06d77edebaa9c171 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 18 Aug 2026 15:42:00 +0800 Subject: [PATCH] fix(sidebar): avoid synchronous selection pruning --- .../src/components/sidebar/ConnectionTree.vue | 12 +++++---- .../ConnectionTree.selectionPruning.spec.ts | 16 ++++++++++++ .../sidebar/sidebarTreeSelection.spec.ts | 26 +++++++++++++++++-- .../src/lib/sidebar/sidebarTreeSelection.ts | 17 ++++++++---- 4 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/components/sidebar/__tests__/ConnectionTree.selectionPruning.spec.ts diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index f6e2414ebd..21a1bacc15 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -30,7 +30,7 @@ import { copyNameForTreeNode, objectSourceTargetForTreeNode } from "@/lib/sideba import { supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities"; import { copyToClipboard } from "@/lib/common/clipboard"; import { connectionPasteTargetGroupId, copySelectedConnectionsToClipboards, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection"; -import { pruneTreeSelectionToVisibleNodes } from "@/lib/sidebar/sidebarTreeSelection"; +import { pruneTreeSelectionToVisibleNodeIds } from "@/lib/sidebar/sidebarTreeSelection"; import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch"; import { isInternalDorisCatalog, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; import { connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; @@ -807,11 +807,13 @@ const selectableVisibleNodes = computed(() => flatTreeIndex.value.se const selectableVisibleNodeIndexById = computed(() => flatTreeIndex.value.selectableVisibleNodeIndexById); const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length)); const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId)); +const hasTreeMultiSelection = computed(() => store.connectionMultiSelectActive || store.selectedTreeNodeIds.length > 1); watch( - visibleNodes, - (nodes) => { - const next = pruneTreeSelectionToVisibleNodes(nodes, { + () => (hasTreeMultiSelection.value ? flatTreeIndex.value.nodeById : null), + (visibleNodeIds) => { + if (!visibleNodeIds) return; + const next = pruneTreeSelectionToVisibleNodeIds(visibleNodeIds, { nodeIds: store.selectedTreeNodeIds, activeNodeId: store.selectedTreeNodeId, anchorNodeId: store.treeSelectionAnchorId, @@ -823,7 +825,7 @@ watch( store.treeSelectionAnchorId = next.anchorNodeId; if (!next.nodeIds.length) store.connectionMultiSelectActive = false; }, - { flush: "sync" }, + { flush: "post" }, ); // --- Sticky database header --- diff --git a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.selectionPruning.spec.ts b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.selectionPruning.spec.ts new file mode 100644 index 0000000000..01fe5cd6e6 --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.selectionPruning.spec.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const connectionTreeSource = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8"); + +describe("ConnectionTree selection pruning", () => { + it("does not track the visible tree outside multi-selection", () => { + expect(connectionTreeSource).toContain("store.connectionMultiSelectActive || store.selectedTreeNodeIds.length > 1"); + expect(connectionTreeSource).toContain("hasTreeMultiSelection.value ? flatTreeIndex.value.nodeById : null"); + expect(connectionTreeSource).not.toMatch(/watch\(\s*visibleNodes,/); + }); + + it("defers selection cleanup until after the tree render", () => { + expect(connectionTreeSource).toMatch(/pruneTreeSelectionToVisibleNodeIds[\s\S]*?\{ flush: "post" \}/); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeSelection.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeSelection.spec.ts index c2f64f0bad..234d068b1c 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeSelection.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeSelection.spec.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { filterSidebarModifierSelectionIds, selectedSidebarBatchTargets, supportsSidebarModifierSelection } from "@/lib/sidebar/sidebarTreeSelection"; +import { describe, expect, it, vi } from "vitest"; +import { filterSidebarModifierSelectionIds, pruneTreeSelectionToVisibleNodeIds, selectedSidebarBatchTargets, supportsSidebarModifierSelection } from "@/lib/sidebar/sidebarTreeSelection"; import type { TreeNode } from "@/types/database"; function node(id: string, type: TreeNode["type"]): TreeNode { @@ -32,6 +32,28 @@ describe("sidebar modifier selection", () => { }); }); +describe("visible sidebar selection pruning", () => { + it("checks only selected identifiers against the existing visible-node index", () => { + const has = vi.fn((id: string) => id === "table-2"); + + expect( + pruneTreeSelectionToVisibleNodeIds( + { has }, + { + nodeIds: ["table-1", "table-2", "table-3"], + activeNodeId: "table-3", + anchorNodeId: "table-1", + }, + ), + ).toEqual({ + nodeIds: ["table-2"], + activeNodeId: "table-2", + anchorNodeId: "table-2", + }); + expect(has).toHaveBeenCalledTimes(5); + }); +}); + describe("sidebar batch action targets", () => { const table = (id: string, connectionId = "connection-1", database = "database-1"): TreeNode => ({ id, diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeSelection.ts b/apps/desktop/src/lib/sidebar/sidebarTreeSelection.ts index a1874c05c4..da4fd0c514 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTreeSelection.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTreeSelection.ts @@ -6,6 +6,10 @@ export type VisibleTreeSelection = { anchorNodeId: string | null; }; +export type VisibleTreeNodeIdLookup = { + has(nodeId: string): boolean; +}; + export function supportsSidebarModifierSelection(node: Pick): boolean { return node.type !== "database" && node.type !== "schema" && !node.type.startsWith("group-"); } @@ -21,14 +25,17 @@ export function selectedTreeNodesInVisibleOrder(visibleNodes: TreeNode[], select return visibleNodes.filter((node) => ids.has(node.id)); } -export function pruneTreeSelectionToVisibleNodes(visibleNodes: readonly TreeNode[], selection: VisibleTreeSelection): VisibleTreeSelection { - const visibleIds = new Set(visibleNodes.map((node) => node.id)); - const nodeIds = selection.nodeIds.filter((id) => visibleIds.has(id)); - const activeNodeId = selection.activeNodeId && visibleIds.has(selection.activeNodeId) ? selection.activeNodeId : (nodeIds[0] ?? null); - const anchorNodeId = selection.anchorNodeId && visibleIds.has(selection.anchorNodeId) ? selection.anchorNodeId : activeNodeId; +export function pruneTreeSelectionToVisibleNodeIds(visibleNodeIds: VisibleTreeNodeIdLookup, selection: VisibleTreeSelection): VisibleTreeSelection { + const nodeIds = selection.nodeIds.filter((id) => visibleNodeIds.has(id)); + const activeNodeId = selection.activeNodeId && visibleNodeIds.has(selection.activeNodeId) ? selection.activeNodeId : (nodeIds[0] ?? null); + const anchorNodeId = selection.anchorNodeId && visibleNodeIds.has(selection.anchorNodeId) ? selection.anchorNodeId : activeNodeId; return { nodeIds, activeNodeId, anchorNodeId }; } +export function pruneTreeSelectionToVisibleNodes(visibleNodes: readonly TreeNode[], selection: VisibleTreeSelection): VisibleTreeSelection { + return pruneTreeSelectionToVisibleNodeIds(new Set(visibleNodes.map((node) => node.id)), selection); +} + export function selectedSidebarBatchTargets(activeNode: TreeNode, selectedNodes: readonly TreeNode[], canUseTarget: (node: TreeNode) => boolean): TreeNode[] { if (selectedNodes.length <= 1 || !selectedNodes.some((node) => node.id === activeNode.id)) return []; const first = selectedNodes[0];