From ce5b96189f67f4f54381794478bae8f001a6aec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Mon, 17 Aug 2026 14:58:48 +0800 Subject: [PATCH] feat(sidebar): support bulk connection group deletion --- .../src/components/sidebar/ConnectionTree.vue | 19 +++ .../sidebar/SidebarTreeItemDialogs.vue | 30 +++- .../sidebar/SidebarTreeRuntimeHost.vue | 12 +- .../src/components/sidebar/TreeItem.vue | 78 +++++++++- .../SidebarTreeItemDialogs.mongoIndex.spec.ts | 59 ++++++++ .../TreeItem.loadMoreActivation.spec.ts | 36 ++++- .../sidebar/sidebarTreeDialogState.ts | 5 + ...onnectionMutationRuntime.selection.spec.ts | 92 ++++++++++- .../useSidebarConnectionMutationRuntime.ts | 63 ++++++-- apps/desktop/src/i18n/locales/en.ts | 8 +- apps/desktop/src/i18n/locales/es.ts | 8 +- apps/desktop/src/i18n/locales/it.ts | 8 +- apps/desktop/src/i18n/locales/ja.ts | 8 +- apps/desktop/src/i18n/locales/ko.ts | 8 +- apps/desktop/src/i18n/locales/pt-BR.ts | 8 +- apps/desktop/src/i18n/locales/zh-CN.ts | 8 +- apps/desktop/src/i18n/locales/zh-TW.ts | 8 +- .../sidebarConnectionMultiSelect.spec.ts | 18 +++ .../sidebarConnectionSelection.spec.ts | 20 ++- .../__tests__/sidebar/sidebarLayout.spec.ts | 49 +++++- .../sidebar/sidebarConnectionMultiSelect.ts | 12 +- .../lib/sidebar/sidebarConnectionSelection.ts | 14 ++ apps/desktop/src/lib/sidebar/sidebarLayout.ts | 63 ++++++-- .../src/lib/sidebar/sidebarTreeSelection.ts | 14 ++ .../connectionStore.oneTimeCleanup.spec.ts | 23 ++- .../__tests__/connectionStore.pinned.spec.ts | 105 ++++++++++++- apps/desktop/src/stores/connectionStore.ts | 143 ++++++++++++++++-- .../app-tests/sidebarLayoutNested.test.ts | 6 +- .../app-tests/sidebarTreeSelection.test.ts | 18 ++- 29 files changed, 864 insertions(+), 79 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index d5d00c25bf..f6e2414ebd 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -30,6 +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 { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch"; import { isInternalDorisCatalog, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; import { connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; @@ -807,6 +808,24 @@ const selectableVisibleNodeIndexById = computed(() => flatTreeIndex.value.select const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length)); const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId)); +watch( + visibleNodes, + (nodes) => { + const next = pruneTreeSelectionToVisibleNodes(nodes, { + nodeIds: store.selectedTreeNodeIds, + activeNodeId: store.selectedTreeNodeId, + anchorNodeId: store.treeSelectionAnchorId, + }); + const selectionChanged = next.nodeIds.length !== store.selectedTreeNodeIds.length || next.nodeIds.some((id, index) => id !== store.selectedTreeNodeIds[index]); + if (!selectionChanged && next.activeNodeId === store.selectedTreeNodeId && next.anchorNodeId === store.treeSelectionAnchorId) return; + store.selectedTreeNodeIds = [...next.nodeIds]; + store.selectedTreeNodeId = next.activeNodeId; + store.treeSelectionAnchorId = next.anchorNodeId; + if (!next.nodeIds.length) store.connectionMultiSelectActive = false; + }, + { flush: "sync" }, +); + // --- Sticky database header --- // RecycleScroller positions each row absolutely, so CSS `position: sticky` on // a database row can't work. Instead we overlay a pinned row from this parent diff --git a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue index fa1f89ecff..c563e0841c 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue @@ -23,6 +23,10 @@ const { moveToNewGroupName, confirmMoveToNewGroup, showDeleteGroupConfirm, + deleteConnectionsWithGroup, + connectionGroupDeleteConfirmMessage, + connectionGroupDeleteMenuLabel, + deletingConnectionGroups, confirmDeleteGroup, showRenameObjectDialog, renameObjectName, @@ -159,6 +163,15 @@ function closeCreateDatabaseResult() { showCreateDatabasePreviewDialog.value = false; } +function updateDeleteGroupDialogOpen(open: boolean) { + if (!open && deletingConnectionGroups.value) return; + showDeleteGroupConfirm.value = open; +} + +function preventDeleteGroupDialogDismiss(event: Event) { + if (deletingConnectionGroups.value) event.preventDefault(); +} + const mongoIndexCollectionName = computed(() => node.value.tableName || node.value.label); function mongoIndexTypeLabel(type: string): string { @@ -232,17 +245,24 @@ watch( - - + + {{ t("connectionGroup.deleteGroupConfirmTitle") }}

- {{ t("connectionGroup.deleteGroupConfirmMessage", { name: node.label }) }} + {{ connectionGroupDeleteConfirmMessage() }}

+ - - + +
diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 201039f157..2609cf12fa 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -280,6 +280,7 @@ import { schemaCommentLoading, schemaCommentPreviewSql, showDeleteGroupConfirm, + deleteConnectionsWithGroup, showMoveToNewGroupDialog, moveToNewGroupName, type DuplicateStructureSource, @@ -354,7 +355,6 @@ const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabas const emit = defineEmits<{ "rename-started": []; - "group-created": [groupId: string]; "request-group-rename": [groupId: string]; "request-saved-sql-rename": [nodeId: string]; "node-toggled": [node: TreeNode, expanded: boolean]; @@ -412,10 +412,13 @@ const { openVisibleDatabasesDialog, openVisibleSchemasDialog, startRenameGroup, + connectionGroupDeleteMenuLabel, + connectionGroupDeleteConfirmMessage, deleteConnectionGroup, newConnectionInGroup, newSubgroup, confirmDeleteGroup, + deletingConnectionGroups, moveToGroup, createGroupAndMoveConnection, } = useSidebarConnectionMutationRuntime({ @@ -425,7 +428,6 @@ const { connectionStore, queryStore, requestGroupRename: (groupId) => emit("request-group-rename", groupId), - groupCreated: (groupId) => emit("group-created", groupId), openVisibleDatabases: (node) => emit("open-visible-databases", node), openVisibleSchemas: (node) => emit("open-visible-schemas", node), }); @@ -4164,6 +4166,10 @@ function connectionDialogCapabilities() { moveToNewGroupName, confirmMoveToNewGroup, showDeleteGroupConfirm, + deleteConnectionsWithGroup, + connectionGroupDeleteConfirmMessage, + connectionGroupDeleteMenuLabel, + deletingConnectionGroups, confirmDeleteGroup, }; } @@ -4624,7 +4630,7 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean }); items.push({ label: "", separator: true }); items.push({ - label: t("connectionGroup.deleteGroup"), + label: connectionGroupDeleteMenuLabel(), action: deleteConnectionGroup, icon: Trash2, shortcut: shortcutDelete, diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 9cf8b07a77..e92f9e5927 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -530,6 +530,14 @@ function visibleTreeNodes(): TreeNode[] { return flattenTree(connectionStore.treeNodes).map((item) => item.node); } +function connectionIdsForSelection(): Set { + return new Set(connectionStore.connections.map((connection) => connection.id)); +} + +function connectionGroupIdsForSelection(): Set { + return new Set(connectionStore.sidebarLayout.groups.map((group) => group.id)); +} + function selectSingleTreeNode(node: TreeNode) { // Re-clicking the selected row should not replace the selection array and // force visible tree rows to recompute. @@ -558,7 +566,8 @@ function toggleTreeNodeSelection(node: TreeNode) { activeNodeId: node.id, anchorNodeId: node.id, }, - new Set(connectionStore.connections.map((connection) => connection.id)), + connectionIdsForSelection(), + connectionGroupIdsForSelection(), ); } @@ -580,7 +589,8 @@ function selectTreeNodeRange(node: TreeNode) { activeNodeId: node.id, anchorNodeId: anchorId, }, - new Set(connectionStore.connections.map((connection) => connection.id)), + connectionIdsForSelection(), + connectionGroupIdsForSelection(), ); return; } @@ -598,7 +608,8 @@ function selectTreeNodeRange(node: TreeNode) { activeNodeId: node.id, anchorNodeId: anchorId, }, - new Set(connectionStore.connections.map((connection) => connection.id)), + connectionIdsForSelection(), + connectionGroupIdsForSelection(), ); } @@ -608,10 +619,20 @@ function selectedConnectionIdsForAction(): string[] { } const isConnectionSelectionChecked = computed(() => { - if (!connectionStore.connectionMultiSelectActive || activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false; + if (!isConnectionMultiSelectActive() || activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false; return connectionStore.selectedTreeNodeIds.includes(activeNode.value.connectionId); }); +function isConnectionGroupMultiSelectActive(): boolean { + if (!connectionStore.connectionMultiSelectActive) return false; + const firstSelectedId = connectionStore.selectedTreeNodeIds[0]; + return !!firstSelectedId && connectionStore.sidebarLayout.groups.some((group) => group.id === firstSelectedId); +} + +function isConnectionMultiSelectActive(): boolean { + return connectionStore.connectionMultiSelectActive && !isConnectionGroupMultiSelectActive(); +} + function toggleConnectionMultiSelection(event: MouseEvent) { event.preventDefault(); event.stopPropagation(); @@ -624,6 +645,40 @@ function toggleConnectionMultiSelection(event: MouseEvent) { rowRef.value?.focus({ preventScroll: true }); } +function selectedConnectionGroupIdsForAction(): string[] { + const groupIds = connectionGroupIdsForSelection(); + return connectionStore.selectedTreeNodeIds.filter((id) => groupIds.has(id)); +} + +function isConnectionGroupSelectionChecked(): boolean { + if (!isConnectionGroupMultiSelectActive() || activeNode.value.type !== "connection-group") return false; + return connectionStore.selectedTreeNodeIds.includes(activeNode.value.id); +} + +function toggleConnectionGroupMultiSelection(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + if (activeNode.value.type !== "connection-group") return; + + const selectedGroupIds = connectionStore.connectionMultiSelectActive ? selectedConnectionGroupIdsForAction() : []; + const nextGroupIds = new Set(selectedGroupIds); + if (nextGroupIds.has(activeNode.value.id)) nextGroupIds.delete(activeNode.value.id); + else nextGroupIds.add(activeNode.value.id); + const nodeIds = [...nextGroupIds]; + const activeNodeId = nextGroupIds.has(activeNode.value.id) ? activeNode.value.id : (nodeIds[0] ?? null); + applyTreeNodeSelection( + connectionStore, + { + nodeIds, + activeNodeId, + anchorNodeId: activeNodeId, + }, + connectionIdsForSelection(), + connectionGroupIdsForSelection(), + ); + rowRef.value?.focus({ preventScroll: true }); +} + async function cancelConnectionAttempt() { if (!activeNode.value.connectionId) return; try { @@ -1387,7 +1442,7 @@ function onKeydown(event: KeyboardEvent) { v-if="node.type === 'connection'" type="button" class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground/55 opacity-0 transition-colors transition-opacity hover:bg-secondary/45 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/sidebar-row:opacity-100" - :class="[{ 'opacity-100': isConnectionSelectionChecked || connectionStore.connectionMultiSelectActive }, isConnecting ? '' : 'ml-auto']" + :class="[{ 'opacity-100': isConnectionSelectionChecked || isConnectionMultiSelectActive() }, isConnecting ? '' : 'ml-auto']" :aria-label="isConnectionSelectionChecked ? t('connectionGroup.deselectConnection') : t('connectionGroup.selectConnection')" @mousedown.stop @click="toggleConnectionMultiSelection" @@ -1395,6 +1450,19 @@ function onKeydown(event: KeyboardEvent) { +