feat(sidebar): collapse child nodes recursively when folding a parent

This commit is contained in:
lxk955
2026-10-11 09:14:17 +08:00
committed by GitHub
parent 0453ac83e5
commit d84539fc42
9 changed files with 351 additions and 8 deletions
@@ -2043,8 +2043,18 @@ function findSchemaNode(nodes: TreeNode[], connId: string, database: string, sch
function onSearchToggle(node: TreeNode) {
if (!isTreeSearchFiltering.value || !node.children) return;
const next = new Set(searchCollapsedIds.value);
if (node.isExpanded) next.add(node.id);
else next.delete(node.id);
if (node.isExpanded) {
next.add(node.id);
const collectDescendants = (children: readonly TreeNode[]) => {
for (const child of children) {
next.add(child.id);
if (child.children?.length) collectDescendants(child.children);
}
};
collectDescendants(node.children);
} else {
next.delete(node.id);
}
searchCollapsedIds.value = next;
}
@@ -246,6 +246,7 @@ import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql";
import { buildDamengCompileViewSql } from "@/lib/database/damengCompileSql";
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
import { createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { collapseSubtreeDescendants } from "@/lib/sidebar/sidebarTreeCollapse";
import { createSidebarMenuContext, normalizeSidebarMenuDescriptors } from "@/lib/sidebar/sidebarTreeMenuDescriptors";
import { driverProfileDatabaseWorkspace } from "@/lib/database/driverProfileExtensions";
import type { SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
@@ -874,6 +875,7 @@ async function toggle(requestId = beginNavigationRequest()) {
if (node.isLoading) {
if (node.isExpanded) {
node.isExpanded = false;
collapseSubtreeDescendants(node);
if (shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
connectionStore.cancelTreeNodeLoad(node.id);
emitNodeToggled(node, true, false);
@@ -888,6 +890,7 @@ async function toggle(requestId = beginNavigationRequest()) {
if (node.type === "connection-group") {
node.isExpanded = !node.isExpanded;
if (!node.isExpanded) collapseSubtreeDescendants(node);
connectionStore.toggleConnectionGroupCollapsed(node.id);
emitNodeToggled(node, wasExpanded);
return;
@@ -895,12 +898,14 @@ async function toggle(requestId = beginNavigationRequest()) {
if (node.type === "group-partitions") {
node.isExpanded = !node.isExpanded;
if (!node.isExpanded) collapseSubtreeDescendants(node);
emitNodeToggled(node, wasExpanded);
return;
}
if (node.type === "table-vgroup") {
node.isExpanded = !node.isExpanded;
if (!node.isExpanded) collapseSubtreeDescendants(node);
if (node.vgroupId) connectionStore.toggleTableVGroupCollapsed(node, node.vgroupId);
emitNodeToggled(node, wasExpanded);
return;
@@ -908,6 +913,7 @@ async function toggle(requestId = beginNavigationRequest()) {
if (node.type === "type" && customTypeCapabilities(currentDatabaseType()).details && node.children !== undefined) {
node.isExpanded = node.children.length > 0 ? !node.isExpanded : false;
if (!node.isExpanded) collapseSubtreeDescendants(node);
emitNodeToggled(node, wasExpanded);
return;
}
@@ -919,6 +925,7 @@ async function toggle(requestId = beginNavigationRequest()) {
// schema and replace the package's member list with unrelated objects.
if (currentDatabaseType() === "xugu" && (node.type === "group-procedures" || node.type === "group-functions") && node.parentType === "package") {
node.isExpanded = !node.isExpanded;
if (!node.isExpanded) collapseSubtreeDescendants(node);
// Package member groups have no group-level reload path: their children
// are hydrated together with the owning package. Releasing a large group
// here would leave it empty on the next local-only expand.
@@ -933,6 +940,7 @@ async function toggle(requestId = beginNavigationRequest()) {
const needsConfiguredTablePageDrain = node.type === "group-tables" && connectionStore.getConfig(node.connectionId || "")?.sidebar_auto_load_all_tables === true && hasTableTreeLoadMore(node.children ?? []);
if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node) && !needsConfiguredTablePageDrain) {
node.isExpanded = !node.isExpanded;
if (!node.isExpanded) collapseSubtreeDescendants(node);
if (wasExpanded && shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded);
return;
@@ -940,18 +948,21 @@ async function toggle(requestId = beginNavigationRequest()) {
if (node.type === "type-attributes" || node.type === "type-methods") {
node.isExpanded = !node.isExpanded;
if (!node.isExpanded) collapseSubtreeDescendants(node);
emitNodeToggled(node, wasExpanded);
return;
}
if (node.type === "saved-sql-root" || node.type === "saved-sql-folder") {
node.isExpanded = !node.isExpanded;
if (!node.isExpanded) collapseSubtreeDescendants(node);
emitNodeToggled(node, wasExpanded);
return;
}
if ((node.type === "group-extensions" || node.type === "group-event-triggers") && connectionStore.canUseLoadedTreeNodeToggle(node)) {
node.isExpanded = !node.isExpanded;
if (!node.isExpanded) collapseSubtreeDescendants(node);
if (wasExpanded && shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded);
return;
@@ -961,6 +972,7 @@ async function toggle(requestId = beginNavigationRequest()) {
// with the tablespace listing; expanding them is a pure local toggle.
if (node.type === "tablespace" || node.type === "group-datafiles") {
node.isExpanded = !node.isExpanded;
if (!node.isExpanded) collapseSubtreeDescendants(node);
emitNodeToggled(node, wasExpanded);
return;
}
@@ -968,6 +980,7 @@ async function toggle(requestId = beginNavigationRequest()) {
if (node.type === "group-tablespaces") {
if (connectionStore.canUseLoadedTreeNodeToggle(node)) {
node.isExpanded = !node.isExpanded;
if (!node.isExpanded) collapseSubtreeDescendants(node);
if (wasExpanded && shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded);
return;
@@ -977,7 +990,10 @@ async function toggle(requestId = beginNavigationRequest()) {
emitNodeToggled(node, wasExpanded);
} catch (e: any) {
if (!isCurrentNavigationRequest(requestId)) return;
if (!wasExpanded) node.isExpanded = false;
if (!wasExpanded) {
node.isExpanded = false;
collapseSubtreeDescendants(node);
}
const errMsg = e?.message || String(e);
if (errMsg.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
@@ -994,6 +1010,7 @@ async function toggle(requestId = beginNavigationRequest()) {
if (node.isExpanded) {
node.isExpanded = false;
collapseSubtreeDescendants(node);
if (shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded, false);
return;
@@ -7304,7 +7321,14 @@ function activateRuntimeNode(node: TreeNode) {
function emitNodeToggled(node: TreeNode, wasExpanded: boolean, expandedOverride?: boolean) {
const liveNode = findSidebarActionTarget(connectionStore.treeNodes, createSidebarActionTarget(node)) ?? node;
activeNode.value = liveNode;
emit("node-toggled", node, expandedOverride ?? !wasExpanded);
const isExpanded = expandedOverride ?? !wasExpanded;
if (!isExpanded) {
collapseSubtreeDescendants(node);
if (liveNode !== node) {
collapseSubtreeDescendants(liveNode);
}
}
emit("node-toggled", node, isExpanded);
}
function activateActionTarget(target: SidebarActionTarget) {
@@ -911,4 +911,135 @@ describe("SidebarTreeRuntimeHost expansion", () => {
expect(connectionStore.loadXuguTablespaces).not.toHaveBeenCalled();
expect(toggled).toHaveBeenCalledWith(datafileGroup, true);
});
it("collapses all child and descendant nodes when a parent node is collapsed", async () => {
const columnsGroup: TreeNode = {
id: "mysql:db:table:users:group-columns",
label: "Columns",
type: "group-columns",
connectionId: "mysql",
database: "db",
tableName: "users",
isExpanded: true,
children: [{ id: "mysql:db:table:users:col:id", label: "id", type: "column", connectionId: "mysql", database: "db", tableName: "users" }],
};
const tableNode: TreeNode = {
id: "mysql:db:table:users",
label: "users",
type: "table",
connectionId: "mysql",
database: "db",
isExpanded: true,
children: [columnsGroup],
};
const tablesGroup: TreeNode = {
id: "mysql:db:group-tables",
label: "Tables",
type: "group-tables",
connectionId: "mysql",
database: "db",
isExpanded: true,
children: [tableNode],
};
const databaseNode: TreeNode = {
id: "mysql:db",
label: "db",
type: "database",
connectionId: "mysql",
database: "db",
isExpanded: true,
children: [tablesGroup],
};
connectionStore.treeNodes = [databaseNode];
connectionStore.loadTables.mockImplementation(async () => {
databaseNode.isExpanded = true;
});
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const toggled = vi.fn((node: TreeNode, expanded: boolean) => {
syncSidebarTreeNodeExpansion(connectionStore.treeNodes, node, expanded);
});
const app = createApp(
defineComponent({
setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: databaseNode, depth: 0, onNodeToggled: toggled }),
}),
);
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
// Collapsing databaseNode
host.value?.toggleNode(databaseNode);
await nextTick();
expect(databaseNode.isExpanded).toBe(false);
expect(tablesGroup.isExpanded).toBe(false);
expect(tableNode.isExpanded).toBe(false);
expect(columnsGroup.isExpanded).toBe(false);
expect(toggled).toHaveBeenCalledWith(databaseNode, false);
// Re-expanding databaseNode
host.value?.toggleNode(databaseNode);
await nextTick();
expect(databaseNode.isExpanded).toBe(true);
// Descendants must remain collapsed after parent is re-expanded
expect(tablesGroup.isExpanded).toBe(false);
expect(tableNode.isExpanded).toBe(false);
expect(columnsGroup.isExpanded).toBe(false);
});
it("collapses all child connections and their descendants when a connection-group collapses", async () => {
const databaseNode: TreeNode = {
id: "mysql:db",
label: "db",
type: "database",
connectionId: "mysql",
database: "db",
isExpanded: true,
children: [],
};
const connectionNode: TreeNode = {
id: "mysql",
label: "MySQL",
type: "connection",
connectionId: "mysql",
isExpanded: true,
children: [databaseNode],
};
const groupNode: TreeNode = {
id: "group-1",
label: "Dev Group",
type: "connection-group",
isExpanded: true,
children: [connectionNode],
};
connectionStore.treeNodes = [groupNode];
connectionStore.toggleConnectionGroupCollapsed = vi.fn();
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const toggled = vi.fn((node: TreeNode, expanded: boolean) => {
syncSidebarTreeNodeExpansion(connectionStore.treeNodes, node, expanded);
});
const app = createApp(
defineComponent({
setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: groupNode, depth: 0, onNodeToggled: toggled }),
}),
);
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
host.value?.toggleNode(groupNode);
await nextTick();
expect(groupNode.isExpanded).toBe(false);
expect(connectionNode.isExpanded).toBe(false);
expect(databaseNode.isExpanded).toBe(false);
});
});
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import type { TreeNode } from "@/types/database";
import { collapseExpandedTreeNodes, collapseSubtreeDescendants } from "@/lib/sidebar/sidebarTreeCollapse";
import { toggleGroupCollapsed, emptyLayout, createGroup } from "@/lib/sidebar/sidebarLayout";
import { toggleTableVGroupCollapsed, emptyTableVGroupLayout, createTableVGroup } from "@/lib/table/tableVGroup";
describe("sidebarTreeCollapse", () => {
it("collapseSubtreeDescendants collapses all child and descendant nodes while keeping parent untouched", () => {
const parent: TreeNode = {
id: "parent",
label: "Parent",
type: "database",
isExpanded: true,
children: [
{
id: "child-1",
label: "Child 1",
type: "schema",
isExpanded: true,
children: [
{
id: "grandchild-1",
label: "Grandchild 1",
type: "group-tables",
isExpanded: true,
children: [
{
id: "table-1",
label: "Table 1",
type: "table",
isExpanded: true,
},
],
},
{
id: "grandchild-2",
label: "Grandchild 2",
type: "group-views",
isExpanded: false,
},
],
},
{
id: "child-2",
label: "Child 2",
type: "schema",
isExpanded: true,
},
],
};
const count = collapseSubtreeDescendants(parent);
expect(count).toBe(4);
// Parent itself is untouched
expect(parent.isExpanded).toBe(true);
// Children and descendants are collapsed
expect(parent.children![0].isExpanded).toBe(false);
expect(parent.children![0].children![0].isExpanded).toBe(false);
expect(parent.children![0].children![0].children![0].isExpanded).toBe(false);
expect(parent.children![0].children![1].isExpanded).toBe(false);
expect(parent.children![1].isExpanded).toBe(false);
});
it("collapseSubtreeDescendants returns 0 when node has no children", () => {
const leaf: TreeNode = {
id: "leaf",
label: "Leaf",
type: "table",
isExpanded: true,
};
expect(collapseSubtreeDescendants(leaf)).toBe(0);
expect(leaf.isExpanded).toBe(true);
});
it("toggleGroupCollapsed collapses all descendant connection groups when parent group collapses", () => {
let layout = emptyLayout();
const g1 = createGroup(layout, "Parent Group");
layout = g1.layout;
const g2 = createGroup(layout, "Child Group", g1.groupId);
layout = g2.layout;
const g3 = createGroup(layout, "Grandchild Group", g2.groupId);
layout = g3.layout;
// Initially all 3 groups are expanded (collapsed = false)
expect(layout.groups.find((g) => g.id === g1.groupId)?.collapsed).toBe(false);
expect(layout.groups.find((g) => g.id === g2.groupId)?.collapsed).toBe(false);
expect(layout.groups.find((g) => g.id === g3.groupId)?.collapsed).toBe(false);
// Collapsing Parent Group collapses itself and all its descendant groups
const collapsed = toggleGroupCollapsed(layout, g1.groupId);
expect(collapsed.groups.find((g) => g.id === g1.groupId)?.collapsed).toBe(true);
expect(collapsed.groups.find((g) => g.id === g2.groupId)?.collapsed).toBe(true);
expect(collapsed.groups.find((g) => g.id === g3.groupId)?.collapsed).toBe(true);
// Expanding Parent Group reopens Parent Group only; descendant groups stay collapsed
const reExpanded = toggleGroupCollapsed(collapsed, g1.groupId);
expect(reExpanded.groups.find((g) => g.id === g1.groupId)?.collapsed).toBe(false);
expect(reExpanded.groups.find((g) => g.id === g2.groupId)?.collapsed).toBe(true);
expect(reExpanded.groups.find((g) => g.id === g3.groupId)?.collapsed).toBe(true);
});
it("toggleTableVGroupCollapsed collapses all descendant virtual groups when parent group collapses", () => {
let layout = emptyTableVGroupLayout();
const g1 = createTableVGroup(layout, "Parent VGroup");
layout = g1.layout;
const g2 = createTableVGroup(layout, "Child VGroup", g1.groupId);
layout = g2.layout;
expect(layout.groups.find((g) => g.id === g1.groupId)?.collapsed).toBe(false);
expect(layout.groups.find((g) => g.id === g2.groupId)?.collapsed).toBe(false);
const collapsed = toggleTableVGroupCollapsed(layout, g1.groupId);
expect(collapsed.groups.find((g) => g.id === g1.groupId)?.collapsed).toBe(true);
expect(collapsed.groups.find((g) => g.id === g2.groupId)?.collapsed).toBe(true);
const reExpanded = toggleTableVGroupCollapsed(collapsed, g1.groupId);
expect(reExpanded.groups.find((g) => g.id === g1.groupId)?.collapsed).toBe(false);
expect(reExpanded.groups.find((g) => g.id === g2.groupId)?.collapsed).toBe(true);
});
});
+22 -1
View File
@@ -647,9 +647,30 @@ export function deleteGroup(layout: SidebarLayout, groupId: string): SidebarLayo
}
export function toggleGroupCollapsed(layout: SidebarLayout, groupId: string): SidebarLayout {
const targetGroup = layout.groups.find((group) => group.id === groupId);
const willCollapse = targetGroup ? !targetGroup.collapsed : false;
if (!willCollapse) {
return {
...layout,
groups: layout.groups.map((group) => (group.id === groupId ? { ...group, collapsed: false } : group)),
};
}
const groupEntry = findGroupEntry(layout.order, groupId);
const descendantGroupIds = new Set<string>();
const collectDescendants = (entry: Extract<SidebarOrderEntry, { type: "group" }>) => {
for (const child of entryChildren(entry)) {
if (child.type === "group") {
descendantGroupIds.add(child.id);
collectDescendants(child);
}
}
};
if (groupEntry) collectDescendants(groupEntry);
return {
...layout,
groups: layout.groups.map((group) => (group.id === groupId ? { ...group, collapsed: !group.collapsed } : group)),
groups: layout.groups.map((group) => (group.id === groupId || descendantGroupIds.has(group.id) ? { ...group, collapsed: true } : group)),
};
}
@@ -15,3 +15,7 @@ export function collapseExpandedTreeNodes(nodes: TreeNode[]): number {
return collapsedCount;
}
export function collapseSubtreeDescendants(node: TreeNode): number {
if (!node.children?.length) return 0;
return collapseExpandedTreeNodes(node.children);
}
@@ -1,9 +1,16 @@
import type { TreeNode } from "@/types/database";
import { findSidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { collapseSubtreeDescendants } from "@/lib/sidebar/sidebarTreeCollapse";
export { collapseSubtreeDescendants } from "@/lib/sidebar/sidebarTreeCollapse";
export function syncSidebarTreeNodeExpansion(nodes: readonly TreeNode[], renderedNode: TreeNode, expanded: boolean): boolean {
const liveNode = findSidebarActionTarget(nodes, renderedNode);
if (!liveNode || liveNode === renderedNode || liveNode.isExpanded === expanded) return false;
if (!liveNode) return false;
if (!expanded) {
collapseSubtreeDescendants(liveNode);
}
if (liveNode === renderedNode || liveNode.isExpanded === expanded) return false;
liveNode.isExpanded = expanded;
return true;
}
+22 -1
View File
@@ -340,9 +340,30 @@ export function deleteTableVGroups(layout: TableVGroupLayout, groupIds: Iterable
}
export function toggleTableVGroupCollapsed(layout: TableVGroupLayout, groupId: string): TableVGroupLayout {
const targetGroup = layout.groups.find((group) => group.id === groupId);
const willCollapse = targetGroup ? !targetGroup.collapsed : false;
if (!willCollapse) {
return {
...layout,
groups: layout.groups.map((group) => (group.id === groupId ? { ...group, collapsed: false } : group)),
};
}
const groupEntry = findGroupEntry(layout.order, groupId);
const descendantGroupIds = new Set<string>();
const collectDescendants = (entry: TableVGroupEntry) => {
for (const child of entryChildren(entry)) {
if (child.type === "group") {
descendantGroupIds.add(child.id);
collectDescendants(child);
}
}
};
if (groupEntry && groupEntry.type === "group") collectDescendants(groupEntry);
return {
...layout,
groups: layout.groups.map((group) => (group.id === groupId ? { ...group, collapsed: !group.collapsed } : group)),
groups: layout.groups.map((group) => (group.id === groupId || descendantGroupIds.has(group.id) ? { ...group, collapsed: true } : group)),
};
}
+6 -1
View File
@@ -126,7 +126,7 @@ import {
import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarNames, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree";
import { spannerDisplayDatabase, spannerSchemaDisplayName } from "@/lib/connection/spannerResourcePath";
import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree";
import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse";
import { collapseExpandedTreeNodes, collapseSubtreeDescendants } from "@/lib/sidebar/sidebarTreeCollapse";
import { findNodePathByIdentity, nodeMatchesRegexScopeIdentity, type SidebarRegexScopeIdentity } from "@/lib/sidebar/sidebarSearchTree";
import { findDatabaseTreeNode } from "@/lib/sidebar/treeRefreshTarget";
import { simpleModeEmptyShellNeedsConfirmedLoad, treeNodeLoadedChildrenContentPresent } from "@/lib/sidebar/treeLoadedChildrenMarker";
@@ -8225,6 +8225,7 @@ export const useConnectionStore = defineStore("connection", () => {
if (!connectionId || !database) return;
if (node.isExpanded && !options?.force) {
node.isExpanded = false;
collapseSubtreeDescendants(node);
if (!sidebarSearchQuery.value && !options?.preserveCollapsedChildren) releaseCollapsedTreeNodeChildren(node.id);
return;
}
@@ -10599,6 +10600,10 @@ export const useConnectionStore = defineStore("connection", () => {
return connectionIdsInGroupsOp(sidebarLayout.value, groupIds);
},
toggleConnectionGroupCollapsed(groupId: string) {
const groupNode = findNode(treeNodes.value, groupId);
if (groupNode) {
collapseSubtreeDescendants(groupNode);
}
updateLayoutAndRebuild(toggleGroupCollapsedOp(sidebarLayout.value, groupId));
},
expandConnectionGroups(groupIds: Iterable<string>) {