From fa6540b739d06f8befe7e29148d88307274ff736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Thu, 6 Aug 2026 00:55:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(sidebar):=20=E4=BF=AE=E5=A4=8D=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E7=AD=9B=E9=80=89=E6=A0=91=E5=88=86=E7=BB=84=E6=8A=98?= =?UTF-8?q?=E5=8F=A0=E5=9C=A8=20scope-only=20=E8=BF=87=E6=BB=A4=E4=B8=8B?= =?UTF-8?q?=E7=A9=BA=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 fix(sidebar): 修复搜索筛选树的分组折叠与重复点击切换 - 在保留搜索子树时应用临时折叠状态,避免空分组重新展开 - 仅在搜索筛选期间记录节点折叠操作 - 允许分组标签双击后的连续点击正常切换展开状态 - 补充侧边栏搜索及节点点击行为测试 * fix(sidebar): collapseAllTreeNodes 按 scope-only 过滤填充 searchCollapsedIds --------- Co-authored-by: t8y2 Co-authored-by: skyler <1156263951@qq.com> --- .../src/components/sidebar/ConnectionTree.vue | 7 ++-- .../sidebar/SidebarTreeRuntimeHost.vue | 2 +- .../src/lib/sidebar/sidebarSearchTree.ts | 15 ++++++- apps/desktop/src/lib/sidebar/treeNodeClick.ts | 3 +- packages/app-tests/sidebarSearchTree.test.ts | 39 +++++++++++++++++++ .../app-tests/sidebarTreeAffordances.test.ts | 4 +- packages/app-tests/treeNodeClick.test.ts | 4 +- 7 files changed, 66 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 9a303e13b..78251775a 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -223,7 +223,6 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise } } -const isSearching = computed(() => !!deferredSearchQuery.value); const sidebarFilterGuards = computed(() => resolveSidebarFilterGuards(showConnectedConnectionsOnly.value, searchQuery.value, hasSearchScopeFilter.value)); // Connected-only filtering changes only root visibility, so descendant-local // features stay available while operations requiring the full root list pause. @@ -1178,7 +1177,7 @@ function findSchemaNode(nodes: TreeNode[], connId: string, database: string, sch } function onSearchToggle(node: TreeNode) { - if (!isSearching.value || !node.children) return; + if (!isTreeSearchFiltering.value || !node.children) return; const next = new Set(searchCollapsedIds.value); if (node.isExpanded) next.add(node.id); else next.delete(node.id); @@ -1441,7 +1440,9 @@ watch(sidebarTableNameFilterOpen, (open) => { function collapseAllTreeNodes() { store.collapseAllTreeNodes(); - if (isSearching.value) { + // 与 onSearchToggle 一致:scope-only 过滤也要填充 searchCollapsedIds, + // 否则 filteredNodes 会用空集合把所有分组重建成展开态,“全部折叠”空操作。 + if (isTreeSearchFiltering.value) { searchCollapsedIds.value = new Set(flatTreeIndex.value.expandableNodeIds); } } diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 65fc0a0e3..8007e7f4e 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -751,7 +751,7 @@ function runRowClickAction(clickDetail: number) { return; } const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation); - if (!shouldRunTreeNodeRowAction(action, clickDetail)) return; + if (!shouldRunTreeNodeRowAction(action, clickDetail, isGroupLabel(node))) return; if (action === "open-data") { scheduleOpenData(node); } else if (action === "open-source") { diff --git a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts index 690ff0c2c..4fdd825cd 100644 --- a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts +++ b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts @@ -33,6 +33,19 @@ export function reuseLiveSidebarTreeNodes(indexedNodes: TreeNode[], liveNodes: r return indexedNodes.map((node) => liveNodesById.get(node.id) ?? node); } +function applySearchCollapsedState(node: TreeNode, collapsedIds: ReadonlySet): TreeNode { + const children = node.children?.map((child) => applySearchCollapsedState(child, collapsedIds)); + const childrenChanged = children?.some((child, index) => child !== node.children?.[index]) ?? false; + const collapsed = collapsedIds.has(node.id); + if (!collapsed && !childrenChanged) return node; + + return { + ...node, + children: childrenChanged ? children : node.children, + isExpanded: collapsed ? false : node.isExpanded, + }; +} + function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabelMatcher | undefined, collapsedIds: ReadonlySet, searchableNodeTypes?: ReadonlySet): TreeNode[] { const filteredNodes: { node: TreeNode; score: number }[] = []; @@ -57,7 +70,7 @@ function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabe // A type-matched table keeps its loaded detail groups after the text query // is cleared instead of being rebuilt with an empty filtered child list. const preservesTypeMatchedTable = !matchLabel && !!selfMatch && node.type === "table"; - const filteredChildren = preservesSubtree ? node.children : node.children ? filterSidebarTreeWithMatcher(node.children, matchLabel, collapsedIds, searchableNodeTypes) : undefined; + const filteredChildren = preservesSubtree ? node.children?.map((child) => applySearchCollapsedState(child, collapsedIds)) : node.children ? filterSidebarTreeWithMatcher(node.children, matchLabel, collapsedIds, searchableNodeTypes) : undefined; if (selfMatch || (filteredChildren && filteredChildren.length > 0)) { if (!node.children || preservesTypeMatchedTable) { diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts index 952ed2364..c568f1c15 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts @@ -43,7 +43,8 @@ export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activa return "none"; } -export function shouldRunTreeNodeRowAction(action: TreeNodeRowAction, clickDetail: number): boolean { +export function shouldRunTreeNodeRowAction(action: TreeNodeRowAction, clickDetail: number, allowRepeatedToggle = false): boolean { + if (action === "toggle" && allowRepeatedToggle) return true; // Double-clicks emit a second click before dblclick; leave that event to the // double-click handler so expandable database rows do not toggle first. return action !== "none" && clickDetail <= 1; diff --git a/packages/app-tests/sidebarSearchTree.test.ts b/packages/app-tests/sidebarSearchTree.test.ts index 0b491b6ef..c4ef771f2 100644 --- a/packages/app-tests/sidebarSearchTree.test.ts +++ b/packages/app-tests/sidebarSearchTree.test.ts @@ -212,6 +212,45 @@ test("preserves loaded children when the connection itself matches search", () = assert.equal(filtered[0]?.children?.[0]?.children?.[0]?.label, "products"); }); +test("temporarily collapses an empty object group within a preserved search subtree", () => { + const tablesGroup: TreeNode = { + id: "conn:1:inventory:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: "conn:1", + database: "inventory", + isExpanded: true, + children: [], + }; + const nodes: TreeNode[] = [ + { + id: "conn:1", + label: "local-mysql", + type: "connection", + connectionId: "conn:1", + isExpanded: true, + children: [ + { + id: "conn:1:inventory", + label: "inventory", + type: "database", + connectionId: "conn:1", + database: "inventory", + isExpanded: true, + children: [tablesGroup], + }, + ], + }, + ]; + + const filtered = filterSidebarTree(nodes, "local", new Set([tablesGroup.id])); + const filteredGroup = filtered[0]?.children?.[0]?.children?.[0]; + + assert.equal(filteredGroup?.isExpanded, false); + assert.equal(tablesGroup.isExpanded, true); + assert.equal(filterSidebarTree(nodes, "local", new Set())[0]?.children?.[0]?.children?.[0]?.isExpanded, true); +}); + test("matches table comments during sidebar search", () => { const nodes: TreeNode[] = [ { diff --git a/packages/app-tests/sidebarTreeAffordances.test.ts b/packages/app-tests/sidebarTreeAffordances.test.ts index eef9895ad..b65a2fc4e 100644 --- a/packages/app-tests/sidebarTreeAffordances.test.ts +++ b/packages/app-tests/sidebarTreeAffordances.test.ts @@ -73,10 +73,12 @@ test("async tree expansion does not restore a stale rendered clone state", () => assert.equal(liveDatabase.isExpanded, true); }); -test("local table search preserves live expansion state", () => { +test("tree filters retain a temporary expansion state", () => { assert.match(connectionTree, /return \{ \.\.\.node, children: matchingChildren \};/); assert.doesNotMatch(connectionTree, /children: matchingChildren,\s*isExpanded:\s*true/); + assert.match(connectionTree, /function onSearchToggle\(node: TreeNode\) \{\s*if \(!isTreeSearchFiltering\.value \|\| !node\.children\) return;/); assert.match(connectionTree, /function onNodeToggled\(node: TreeNode, wasExpanded: boolean\) \{\s*if \(isTreeSearchFiltering\.value\) return;\s*syncSidebarTreeNodeExpansion\(store\.treeNodes, node, !wasExpanded\)/); + assert.match(runtimeHost, /shouldRunTreeNodeRowAction\(action, clickDetail, isGroupLabel\(node\)\)/); }); test("tree rebuilds keep a context menu only while its target row remains visible", () => { diff --git a/packages/app-tests/treeNodeClick.test.ts b/packages/app-tests/treeNodeClick.test.ts index d72ea9503..b607c136f 100644 --- a/packages/app-tests/treeNodeClick.test.ts +++ b/packages/app-tests/treeNodeClick.test.ts @@ -68,10 +68,12 @@ test("document browser helper covers Mongo collections and GridFS buckets", () = assert.equal(isDocumentBrowserTreeNode("redis-db"), false); }); -test("double-click follow-up clicks do not run row actions", () => { +test("double-click follow-up clicks do not repeat side-effecting row actions", () => { assert.equal(shouldRunTreeNodeRowAction("toggle", 1), true); assert.equal(shouldRunTreeNodeRowAction("toggle", 2), false); assert.equal(shouldRunTreeNodeRowAction("toggle", 3), false); + assert.equal(shouldRunTreeNodeRowAction("toggle", 2, true), true); + assert.equal(shouldRunTreeNodeRowAction("toggle", 3, true), true); assert.equal(shouldRunTreeNodeRowAction("open-data", 1), true); assert.equal(shouldRunTreeNodeRowAction("open-data", 2), false); assert.equal(shouldRunTreeNodeRowAction("open-source", 1), true);