feat(sidebar): 添加一键折叠所有节点功能

- 在侧边栏中添加了“折叠所有”按钮,用户可以一键折叠所有展开的节点。
- 更新了相关的国际化文本,支持多语言。
- 增加了 collapseAllGroups 和 collapseExpandedTreeNodes 函数以支持新功能。
This commit is contained in:
二丫讲梵 2026-07-02 18:50:01 +08:00 committed by GitHub
parent e7dffeca37
commit af9ad39ffb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 191 additions and 10 deletions

View File

@ -2,7 +2,7 @@
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import { Upload, Download, FolderPlus, RefreshCw, ChevronsLeft } from "@lucide/vue";
import { Upload, Download, FolderPlus, RefreshCw, ChevronsLeft, ChevronsUp } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import LightDropdown from "@/components/ui/LightDropdown.vue";
@ -45,6 +45,10 @@ function createNewGroup() {
void connectionTreeRef.value?.createNewGroup();
}
function collapseAllTreeNodes() {
connectionTreeRef.value?.collapseAllTreeNodes();
}
function focusSearch(): boolean {
return connectionTreeRef.value?.focusSearch() ?? false;
}
@ -88,6 +92,14 @@ defineExpose({ focusSearch });
</TooltipTrigger>
<TooltipContent>{{ t("sidebar.export") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="collapseAllTreeNodes">
<ChevronsUp class="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("sidebar.collapseAll") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="createNewGroup">

View File

@ -702,6 +702,13 @@ function onSearchToggle(node: TreeNode) {
searchCollapsedIds.value = next;
}
function collapseAllTreeNodes() {
store.collapseAllTreeNodes();
if (isSearching.value) {
searchCollapsedIds.value = new Set(flatNodes.value.filter((item) => item.node.children?.length).map((item) => item.id));
}
}
function currentTreeScroller(): HTMLElement | null {
return ((useVirtualTree.value ? treeScrollerRef.value?.$el : plainTreeScrollerRef.value) as HTMLElement | undefined) ?? null;
}
@ -859,7 +866,7 @@ onUnmounted(() => {
window.clearTimeout(sidebarScrollingTimer);
});
defineExpose({ focusSearch, createNewGroup });
defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
</script>
<template>
@ -920,7 +927,7 @@ defineExpose({ focusSearch, createNewGroup });
:prerender="SIDEBAR_TREE_PRERENDER_COUNT"
:skip-hover="true"
key-field="id"
type-field="type"
type-field="poolType"
flow-mode
>
<template #default="{ item }">

View File

@ -76,7 +76,7 @@ import { clearActiveTableReferencePayload, createTableReferencePayload, createTa
import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { tableOpenPageLimit } from "@/lib/tableOpenPageLimit";
import { supportsDatabaseCreation, supportsDatabaseSearch, supportsFieldLineage, supportsObjectBrowserTreeNode, supportsSchemaDiagram, supportsSqlFileExecution, supportsTableImport, supportsTableTruncate, supportsTableStructureEditing, usesTreeSchemaMode } from "@/lib/databaseCapabilities";
import { copyNameForTreeNode, objectSourceKindForTreeNode, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick";
import { copyNameForTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick";
import { formatSqlInsert } from "@/lib/exportFormats";
import { joinExportedDdls } from "@/lib/ddlExport";
import { fetchTableDataForExport } from "@/lib/tableDataExport";
@ -636,17 +636,20 @@ async function toggle() {
}
}
function runRowClickAction() {
function runRowClickAction(clickDetail: number) {
const node = props.node;
if (node.type === "load-more") {
if (clickDetail > 1) return;
void loadMoreObjectGroupChildren();
return;
}
if (node.type === "object-browser") {
if (clickDetail > 1) return;
void openObjectBrowser();
return;
}
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation);
if (!shouldRunTreeNodeRowAction(action, clickDetail)) return;
if (action === "open-data") {
openData();
} else if (node.type === "mongo-collection") {
@ -680,6 +683,18 @@ async function loadAllObjectGroupChildren() {
}
}
function onToggleClick() {
selectSingleTreeNode(props.node);
rowRef.value?.focus({ preventScroll: true });
void toggle();
}
function onToggleMouseDown(event: MouseEvent) {
if (event.button !== 0) return;
selectSingleTreeNode(props.node);
rowRef.value?.focus({ preventScroll: true });
}
function visibleTreeNodes(): TreeNode[] {
if (sidebarTreeContext) return sidebarTreeContext.getVisibleNodes();
return flattenTree(connectionStore.treeNodes).map((item) => item.node);
@ -749,8 +764,7 @@ function onClick(event: MouseEvent) {
selectSingleTreeNode(props.node);
rowRef.value?.focus({ preventScroll: true });
if (settingsStore.editorSettings.sidebarActivation === "double") return;
if (event.detail > 1) return;
runRowClickAction();
runRowClickAction(event.detail);
}
function onTreeItemContextMenu(event: MouseEvent, openContextMenu: (event: MouseEvent) => void) {
@ -4487,7 +4501,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
<div v-if="showDropBefore" class="absolute right-2 top-0 h-0.5 bg-primary rounded-full pointer-events-none" :style="{ left: paddingLeft }" />
<div v-if="showDropAfter" class="absolute right-2 bottom-0 h-0.5 bg-primary rounded-full pointer-events-none" :style="{ left: paddingLeft }" />
<template v-if="canExpand">
<button type="button" class="-m-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground" @click.stop="toggle">
<button type="button" class="-m-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground" @mousedown.stop="onToggleMouseDown" @click.stop="onToggleClick">
<Loader2 v-if="node.isLoading" class="w-3.5 h-3.5 animate-spin" />
<ChevronDown v-else-if="node.isExpanded" class="w-3.5 h-3.5" />
<ChevronRight v-else class="w-3.5 h-3.5" />

View File

@ -9,11 +9,18 @@ export interface FlatTreeNode {
depth: number;
id: string;
type: TreeNodeType;
poolType: string;
}
function walk(children: TreeNode[], depth: number, result: FlatTreeNode[]) {
for (const node of children) {
result.push({ node, depth, id: node.id, type: node.type });
result.push({
node,
depth,
id: node.id,
type: node.type,
poolType: node.type === "connection-group" ? `${node.type}:${node.id}` : node.type,
});
if (node.isExpanded && node.children) {
walk(node.children, depth + 1, result);
}

View File

@ -85,6 +85,7 @@ export default {
importDbeaver: "Import DBeaver",
importDatagrip: "Import DataGrip",
export: "Export Connections",
collapseAll: "Collapse all",
collapse: "Collapse sidebar",
expand: "Expand sidebar",
showMore: "Show {count} more...",

View File

@ -87,6 +87,7 @@ export default withEnglishFallback({
importDbeaver: "Importar DBeaver",
importDatagrip: "Importar DataGrip",
export: "Exportar conexiones",
collapseAll: "Contraer todo",
collapse: "Contraer barra lateral",
expand: "Expandir barra lateral",
showMore: "Mostrar {count} más...",

View File

@ -86,6 +86,7 @@ export default withEnglishFallback({
importDbeaver: "Importa DBeaver",
importDatagrip: "Importa DataGrip",
export: "Esporta Connessioni",
collapseAll: "Riduci tutto",
collapse: "Riduci barra laterale",
expand: "Espandi barra laterale",
showMore: "Mostra altri {count}...",

View File

@ -87,6 +87,7 @@ export default withEnglishFallback({
importDbeaver: "DBeaverをインポート",
importDatagrip: "DataGripをインポート",
export: "接続をエクスポート",
collapseAll: "すべて折りたたむ",
collapse: "サイドバーを折りたたむ",
expand: "サイドバーを展開",
showMore: "さらに{count}件表示...",

View File

@ -87,6 +87,7 @@ export default withEnglishFallback({
importDbeaver: "Importar DBeaver",
importDatagrip: "Importar DataGrip",
export: "Exportar Conexões",
collapseAll: "Recolher tudo",
collapse: "Recolher barra lateral",
expand: "Expandir barra lateral",
showMore: "Mostrar mais {count}...",

View File

@ -87,6 +87,7 @@ export default withEnglishFallback({
importDbeaver: "导入 DBeaver",
importDatagrip: "导入 DataGrip",
export: "导出连接",
collapseAll: "一键折叠",
collapse: "收起侧边栏",
expand: "展开侧边栏",
showMore: "加载更多 ({count})...",

View File

@ -87,6 +87,7 @@ export default withEnglishFallback({
importDbeaver: "匯入 DBeaver",
importDatagrip: "匯入 DataGrip",
export: "匯出連線",
collapseAll: "一鍵收合",
collapse: "收合側邊欄",
expand: "展開側邊欄",
showMore: "再顯示 {count} 個……",

View File

@ -309,6 +309,13 @@ export function toggleGroupCollapsed(layout: SidebarLayout, groupId: string): Si
};
}
export function collapseAllGroups(layout: SidebarLayout): SidebarLayout {
return {
...layout,
groups: layout.groups.map((group) => (group.collapsed ? group : { ...group, collapsed: true })),
};
}
export function removeConnectionFromSidebarLayout(layout: SidebarLayout, connectionId: string): SidebarLayout {
return { ...layout, order: removeConnectionFromEntries(layout.order, connectionId) };
}

View File

@ -0,0 +1,17 @@
import type { TreeNode } from "@/types/database";
export function collapseExpandedTreeNodes(nodes: TreeNode[]): number {
let collapsedCount = 0;
for (const node of nodes) {
if (node.isExpanded) {
node.isExpanded = false;
collapsedCount += 1;
}
if (node.children?.length) {
collapsedCount += collapseExpandedTreeNodes(node.children);
}
}
return collapsedCount;
}

View File

@ -33,6 +33,11 @@ export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activa
return "none";
}
export function shouldRunTreeNodeRowAction(action: TreeNodeRowAction, clickDetail: number): boolean {
if (action === "none") return false;
return action === "toggle" || clickDetail <= 1;
}
export function treeNodeRowDoubleClickAction(type: TreeNodeType, canOpenObjectBrowser: boolean, activation: SidebarActivation = "single", canExpand = false): TreeNodeRowDoubleClickAction {
if (activation === "double") {
if (dataNodeTypes.has(type)) return "open-data";

View File

@ -14,6 +14,7 @@ import {
renameGroup as renameGroupOp,
deleteGroup as deleteGroupOp,
toggleGroupCollapsed as toggleGroupCollapsedOp,
collapseAllGroups as collapseAllGroupsOp,
moveConnectionToGroup as moveConnectionToGroupOp,
remapSidebarLayoutConnectionIds,
reorderEntry as reorderEntryOp,
@ -26,6 +27,7 @@ import { isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabas
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/databaseTree";
import { buildSqlServerDatabaseTreeNodes } from "@/lib/sqlServerTree";
import { collapseExpandedTreeNodes } from "@/lib/sidebarTreeCollapse";
import { findDatabaseTreeNode } from "@/lib/treeRefreshTarget";
import { shouldMarkDisconnected } from "@/lib/connectionHealth";
import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connectionAttemptTimeout";
@ -3505,6 +3507,11 @@ export const useConnectionStore = defineStore("connection", () => {
persistSidebarLayoutDebounced();
}
function collapseAllTreeNodes() {
updateLayoutAndRebuild(collapseAllGroupsOp(sidebarLayout.value));
collapseExpandedTreeNodes(treeNodes.value);
}
async function refreshAllTree() {
const expandedIds = collectExpandedNodeIds(treeNodes.value);
const refreshExpandedNodes = async (nodes: TreeNode[]) => {
@ -3936,6 +3943,7 @@ export const useConnectionStore = defineStore("connection", () => {
treeNodes,
removeTreeNode,
refreshAllTree,
collapseAllTreeNodes,
refreshSidebarObjectPagination,
refreshTreeNode,
refreshDatabaseTreeNode,

View File

@ -13,6 +13,7 @@ import {
removeConnectionFromSidebarLayout,
emptyLayout,
remapSidebarLayoutConnectionIds,
collapseAllGroups,
} from "../../apps/desktop/src/lib/sidebarLayout.ts";
import type { ConnectionConfig, SidebarLayout } from "../../apps/desktop/src/types/database.ts";
@ -213,6 +214,37 @@ test("toggleGroupCollapsed flips collapsed state", () => {
assert.equal(toggled.groups[0].collapsed, true);
});
test("collapseAllGroups keeps other groups collapsed after one group is reopened", async () => {
const layout: SidebarLayout = {
groups: [
{ id: "g1", name: "G1", collapsed: false },
{ id: "g2", name: "G2", collapsed: false },
],
order: [
{ type: "group", id: "g1", children: [{ type: "connection", id: "a" }] },
{ type: "group", id: "g2", children: [{ type: "connection", id: "b" }] },
],
};
const collapsed = collapseAllGroups(layout);
assert.deepEqual(
collapsed.groups.map((group) => [group.id, group.collapsed]),
[
["g1", true],
["g2", true],
],
);
const reopenedFirst = toggleGroupCollapsed(collapsed, "g1");
assert.deepEqual(
reopenedFirst.groups.map((group) => [group.id, group.collapsed]),
[
["g1", false],
["g2", true],
],
);
});
// --- moveConnectionToGroup ---
test("moveConnectionToGroup moves connection into a group", () => {

View File

@ -0,0 +1,42 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { collapseExpandedTreeNodes } from "../../apps/desktop/src/lib/sidebarTreeCollapse.ts";
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
test("collapseExpandedTreeNodes collapses all expanded descendants", () => {
const nodes: TreeNode[] = [
{
id: "conn",
label: "conn",
type: "connection",
isExpanded: true,
children: [
{
id: "db",
label: "db",
type: "database",
isExpanded: true,
children: [
{ id: "tables", label: "Tables", type: "group-tables", isExpanded: false },
{ id: "views", label: "Views", type: "group-views", isExpanded: true },
],
},
],
},
{ id: "closed", label: "closed", type: "connection", isExpanded: false },
];
assert.equal(collapseExpandedTreeNodes(nodes), 3);
assert.equal(nodes[0].isExpanded, false);
assert.equal(nodes[0].children?.[0]?.isExpanded, false);
assert.equal(nodes[0].children?.[0]?.children?.[0]?.isExpanded, false);
assert.equal(nodes[0].children?.[0]?.children?.[1]?.isExpanded, false);
assert.equal(nodes[1].isExpanded, false);
});
test("collapseExpandedTreeNodes returns zero when nothing is expanded", () => {
const nodes: TreeNode[] = [{ id: "conn", label: "conn", type: "connection", isExpanded: false }];
assert.equal(collapseExpandedTreeNodes(nodes), 0);
assert.equal(nodes[0].isExpanded, false);
});

View File

@ -1,6 +1,6 @@
import { test } from "vitest";
import assert from "node:assert/strict";
import { copyNameForTreeNode, objectSourceKindForTreeNode, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "../../apps/desktop/src/lib/treeNodeClick.ts";
import { copyNameForTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "../../apps/desktop/src/lib/treeNodeClick.ts";
test("table and view rows open data without toggling structure groups", () => {
assert.equal(treeNodeRowAction("table", true), "open-data");
@ -43,6 +43,14 @@ test("leaf data browser nodes keep their open behavior through toggle handler",
assert.equal(treeNodeRowAction("mongo-collection", false), "toggle");
});
test("repeated clicks continue to toggle expandable rows", () => {
assert.equal(shouldRunTreeNodeRowAction("toggle", 1), true);
assert.equal(shouldRunTreeNodeRowAction("toggle", 2), true);
assert.equal(shouldRunTreeNodeRowAction("toggle", 3), true);
assert.equal(shouldRunTreeNodeRowAction("open-data", 2), false);
assert.equal(shouldRunTreeNodeRowAction("none", 1), false);
});
test("plain metadata leaf rows do nothing on row clicks", () => {
assert.equal(treeNodeRowAction("column", false), "none");
assert.equal(treeNodeRowAction("index", false), "none");

View File

@ -36,6 +36,21 @@ test("flattenTree preserves depth and node type for virtualized sidebar rows", (
);
});
test("connection groups use per-node pool types to avoid recycled row state", () => {
const nodes: TreeNode[] = [
{ id: "g1", label: "Group 1", type: "connection-group", isExpanded: false },
{ id: "g2", label: "Group 2", type: "connection-group", isExpanded: false },
{ id: "c1", label: "Connection", type: "connection", isExpanded: false },
];
const flat = flattenTree(nodes);
assert.equal(flat[0].type, "connection-group");
assert.equal(flat[1].type, "connection-group");
assert.notEqual(flat[0].poolType, flat[1].poolType);
assert.equal(flat[2].poolType, "connection");
});
test("shouldVirtualizeFlatTree virtualizes every non-empty sidebar tree", () => {
assert.equal(shouldVirtualizeFlatTree(0), false);
assert.equal(shouldVirtualizeFlatTree(1), true);