diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index e4d6eb4ae0..68f295e6ae 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -1567,6 +1567,16 @@ async function locateTabInSidebar(tab: QueryTab | undefined | null, align: Sideb } } + // Connection groups never register loaded tree children, so the guard above + // skips them and a collapsed group keeps the target out of the visible flat + // tree. Reopen them through the layout op so the expansion is persisted and + // survives the next layout rebuild; flipping isExpanded directly would be + // reverted by that rebuild (issue #7387). + const collapsedGroupIds = nodePath.filter((node) => node.type === "connection-group" && !node.isExpanded).map((node) => node.id); + if (collapsedGroupIds.length > 0) { + store.expandConnectionGroups(collapsedGroupIds); + } + await nextTick(); const match = target ? findSidebarNodeForTarget(target, flatNodes.value) : null; diff --git a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.locateCollapsedGroup.spec.ts b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.locateCollapsedGroup.spec.ts new file mode 100644 index 0000000000..4c2b68bd4d --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.locateCollapsedGroup.spec.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const connectionTreeSource = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8"); + +describe("ConnectionTree locate in collapsed groups", () => { + it("reopens collapsed connection groups on the node path through the persisted layout op", () => { + // Group expansion must flow through store.expandConnectionGroups so + // layout.collapsed is flipped and the next layout rebuild keeps the group + // open; flipping isExpanded directly would be reverted by that rebuild. + expect(connectionTreeSource).toMatch(/const collapsedGroupIds = nodePath\s*\.filter\(\(node\) => node\.type === "connection-group" && !node\.isExpanded\)\s*\.map\(\(node\) => node\.id\);/); + // The expansion happens before the flat-tree match that drives selection, + // scrolling and flashing, so the reopened groups are visible to it. + expect(connectionTreeSource).toMatch(/store\.expandConnectionGroups\(collapsedGroupIds\);\s*\}\s*\n\s*await nextTick\(\);\s*\n\s*const match = target \? findSidebarNodeForTarget\(target, flatNodes\.value\) : null;/); + }); + + it("still gates generic ancestor expansion on loaded children only (#5850)", () => { + expect(connectionTreeSource).toMatch(/if \(!ancestor\.isExpanded && store\.canUseLoadedTreeNodeToggle\(ancestor\)\) \{\s*ancestor\.isExpanded = true;\s*\}/); + }); +}); diff --git a/packages/app-tests/sidebarActiveTabTarget.test.ts b/packages/app-tests/sidebarActiveTabTarget.test.ts index f90de252b4..81f1ac58a4 100644 --- a/packages/app-tests/sidebarActiveTabTarget.test.ts +++ b/packages/app-tests/sidebarActiveTabTarget.test.ts @@ -1,8 +1,10 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { activeTabSidebarTarget, findNodePathForTarget, findSidebarConnectionNode, findSidebarNodeForActiveTab, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection } from "../../apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts"; +import { activeTabSidebarTarget, findNodePathForTarget, findSidebarConnectionNode, findSidebarNodeForActiveTab, findSidebarNodeForTarget, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection } from "../../apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts"; +import { buildTreeNodesFromLayout, expandGroups } from "../../apps/desktop/src/lib/sidebar/sidebarLayout.ts"; +import { flattenTree } from "../../apps/desktop/src/composables/useFlatTree.ts"; import type { FlatTreeNode } from "../../apps/desktop/src/composables/useFlatTree.ts"; -import type { QueryTab, TreeNode } from "../../apps/desktop/src/types/database.ts"; +import type { ConnectionConfig, QueryTab, SidebarLayout, TreeNode } from "../../apps/desktop/src/types/database.ts"; function flat(node: TreeNode, depth = 0): FlatTreeNode { return { id: node.id, node, depth, type: node.type }; @@ -578,3 +580,59 @@ test("active sidebar selection only scrolls on tab or setting changes", () => { true, ); }); + +test("locate under collapsed connection groups resolves the path, expands the groups, then finds the flat node", () => { + // Mirrors locateTabInSidebar for a target inside collapsed (nested) + // connection groups: the node path ignores expansion, the visible flat tree + // does not, so locate must reopen the collapsed groups through the layout op + // and rebuild before matching, otherwise the locate silently fails (#7387). + const conn = (id: string): ConnectionConfig => ({ + id, + name: id, + db_type: "postgres", + host: "localhost", + port: 5432, + username: "user", + password: "", + }); + const layout: SidebarLayout = { + groups: [ + { id: "group-outer", name: "Outer", collapsed: true }, + { id: "group-inner", name: "Inner", collapsed: true }, + { id: "group-open", name: "Open", collapsed: false }, + ], + order: [ + { + type: "group", + id: "group-outer", + children: [{ type: "group", id: "group-inner", children: [{ type: "connection", id: "conn-grouped" }] }], + }, + { type: "group", id: "group-open", children: [{ type: "connection", id: "conn-open" }] }, + ], + }; + const connections = [conn("conn-grouped"), conn("conn-open")]; + const target = { type: "connection", connectionId: "conn-grouped" } as const; + + const collapsedTree = buildTreeNodesFromLayout(layout, connections, new Set()); + const nodePath = findNodePathForTarget(target, collapsedTree); + assert.deepEqual(nodePath?.map((node) => node.id), ["group-outer", "group-inner", "conn-grouped"]); + assert.equal(findSidebarNodeForTarget(target, flattenTree(collapsedTree)), null); + + const collapsedGroupIds = nodePath! + .filter((node) => node.type === "connection-group" && !node.isExpanded) + .map((node) => node.id); + assert.deepEqual(collapsedGroupIds, ["group-outer", "group-inner"]); + + const expandedLayout = expandGroups(layout, collapsedGroupIds); + assert.deepEqual( + expandedLayout.groups.map((group) => [group.id, group.collapsed]), + [ + ["group-outer", false], + ["group-inner", false], + ["group-open", false], + ], + ); + + const rebuiltTree = buildTreeNodesFromLayout(expandedLayout, connections, new Set()); + assert.equal(findSidebarNodeForTarget(target, flattenTree(rebuiltTree))?.id, "conn-grouped"); +}); diff --git a/packages/app-tests/sidebarLayout.test.ts b/packages/app-tests/sidebarLayout.test.ts index 62f48f43ed..a9d9378146 100644 --- a/packages/app-tests/sidebarLayout.test.ts +++ b/packages/app-tests/sidebarLayout.test.ts @@ -9,6 +9,7 @@ import { moveConnectionToGroup, reorderEntry, toggleGroupCollapsed, + expandGroups, appendConnectionToLayout, removeConnectionFromSidebarLayout, emptyLayout, @@ -381,6 +382,35 @@ test("collapseAllGroups keeps other groups collapsed after one group is reopened ); }); +// --- expandGroups --- + +test("expandGroups reopens collapsed groups and is a no-op otherwise", () => { + const layout: SidebarLayout = { + groups: [ + { id: "g1", name: "G1", collapsed: true }, + { id: "g2", name: "G2", collapsed: false }, + ], + order: [ + { type: "group", id: "g1", children: [{ type: "connection", id: "a" }] }, + { type: "group", id: "g2", children: [] }, + ], + }; + + const expanded = expandGroups(layout, ["g1", "g2", "missing"]); + assert.deepEqual( + expanded.groups.map((group) => [group.id, group.collapsed]), + [ + ["g1", false], + ["g2", false], + ], + ); + + // Nothing to reopen: the same layout reference comes back so callers skip + // unnecessary rebuilds and persistence. + assert.equal(expandGroups(expanded, ["g1"]), expanded); + assert.equal(expandGroups(layout, []), layout); +}); + // --- moveConnectionToGroup --- test("moveConnectionToGroup moves connection into a group", () => {