diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 4d7ce6833a..a61dbd7bfe 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -425,6 +425,9 @@ const { disconnectConnection, connectionDisconnectMenuLabel, canDisconnectConnection, + connectionGroupDisconnectMenuLabel, + canDisconnectConnectionGroup, + disconnectConnectionGroup, canForgetSessionCredential, disconnectAndForgetConnectionPassword, cancelConnectionAttempt, @@ -4963,6 +4966,12 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean items.push({ label: "", separator: true }); items.push({ label: t("toolbar.newConnection"), action: newConnectionInGroup, icon: Plus }); items.push({ label: t("connectionGroup.newGroup"), action: newSubgroup, icon: FolderPlus }); + items.push({ + label: connectionGroupDisconnectMenuLabel(), + action: disconnectConnectionGroup, + icon: Unplug, + disabled: !canDisconnectConnectionGroup(), + }); items.push({ label: "", separator: true }); items.push({ label: t("connectionGroup.renameGroup"), diff --git a/apps/desktop/src/composables/__tests__/useSidebarConnectionMutationRuntime.selection.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarConnectionMutationRuntime.selection.spec.ts index 29204c129d..1bc524e377 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarConnectionMutationRuntime.selection.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarConnectionMutationRuntime.selection.spec.ts @@ -67,6 +67,7 @@ function connectionStore(selectedTreeNodeIds: string[]) { connectedIds: new Set(), connectingIds: new Set(), disconnect: vi.fn().mockResolvedValue(undefined), + disconnectAndForgetConnectionPassword: vi.fn().mockResolvedValue(undefined), isTreeNodeChildrenLoaded: vi.fn(() => false), getConfig: vi.fn(() => undefined), isDefaultDatabase: vi.fn(() => false), @@ -244,6 +245,60 @@ describe("sidebar connection group deletion selection", () => { }); }); +describe("sidebar connection group disconnect", () => { + beforeEach(() => { + vi.clearAllMocks(); + sidebarFormTarget.value = null; + }); + + it("recursively disconnects only connected connections under a collapsed group", async () => { + const group = connectionGroupNode("group-parent"); + group.isExpanded = false; + const store = connectionStore([group.id]); + store.connectionIdsInGroups.mockReturnValue(["conn-1", "conn-2", "conn-3"]); + store.connectedIds = new Set(["conn-1", "conn-3"]); + const { canDisconnectConnectionGroup, connectionGroupDisconnectMenuLabel, disconnectConnectionGroup } = runtime(group, store); + + expect(canDisconnectConnectionGroup()).toBe(true); + expect(connectionGroupDisconnectMenuLabel()).toBe('connectionGroup.closeConnections:{"count":2}'); + await disconnectConnectionGroup(); + + expect(store.connectionIdsInGroups).toHaveBeenCalledWith(["group-parent"]); + expect(store.disconnect.mock.calls).toEqual([["conn-1"], ["conn-3"]]); + expect(store.removeConnections).not.toHaveBeenCalled(); + expect(store.deleteConnectionGroups).not.toHaveBeenCalled(); + expect(store.disconnectAndForgetConnectionPassword).not.toHaveBeenCalled(); + }); + + it("disables and safely no-ops when the group has no connected connections", async () => { + const group = connectionGroupNode("group-parent"); + const store = connectionStore([group.id]); + store.connectionIdsInGroups.mockReturnValue(["conn-1", "conn-2"]); + const { canDisconnectConnectionGroup, connectionGroupDisconnectMenuLabel, disconnectConnectionGroup } = runtime(group, store); + + expect(canDisconnectConnectionGroup()).toBe(false); + expect(connectionGroupDisconnectMenuLabel()).toBe('connectionGroup.closeConnections:{"count":0}'); + await disconnectConnectionGroup(); + + expect(store.disconnect).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + + it("continues group disconnects after one connection fails", async () => { + const group = connectionGroupNode("group-parent"); + const store = connectionStore([group.id]); + store.connectionIdsInGroups.mockReturnValue(["conn-1", "conn-2"]); + store.connectedIds = new Set(["conn-1", "conn-2"]); + store.disconnect.mockRejectedValueOnce(new Error("failed")).mockResolvedValueOnce(undefined); + const { disconnectConnectionGroup } = runtime(group, store); + + await disconnectConnectionGroup(); + + expect(store.disconnect.mock.calls).toEqual([["conn-1"], ["conn-2"]]); + expect(mocks.toast).toHaveBeenCalledWith('connection.disconnectSelectedPartial:{"succeeded":1,"failed":1}', 5000); + }); +}); + describe("sidebar connection disconnect selection", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts b/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts index 35b184a329..4ee14a1fba 100644 --- a/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts +++ b/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts @@ -212,16 +212,12 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu } } - async function disconnectConnection() { - const targets = selectedConnectionDisconnectTargets(activeNode.value, selectedTreeNodesInVisibleOrder()).filter((target) => connectionStore.connectedIds.has(target.connectionId)); - if (!targets.length) return; + async function disconnectConnectionIds(connectionIds: readonly string[]) { + if (!connectionIds.length) return; - const result = await disconnectSidebarConnections( - targets.map((target) => target.connectionId), - (connectionId) => connectionStore.disconnect(connectionId), - ); + const result = await disconnectSidebarConnections(connectionIds, (connectionId) => connectionStore.disconnect(connectionId)); if (!result.failed) { - toast(targets.length > 1 ? t("connection.disconnectedSelected", { count: targets.length }) : t("connection.disconnected"), 2000); + toast(connectionIds.length > 1 ? t("connection.disconnectedSelected", { count: connectionIds.length }) : t("connection.disconnected"), 2000); return; } if (result.succeeded > 0) { @@ -232,6 +228,13 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu toast(t("connection.saveFailed", { message }), 5000); } + async function disconnectConnection() { + const connectionIds = selectedConnectionDisconnectTargets(activeNode.value, selectedTreeNodesInVisibleOrder()) + .filter((target) => connectionStore.connectedIds.has(target.connectionId)) + .map((target) => target.connectionId); + await disconnectConnectionIds(connectionIds); + } + function connectionDisconnectTargets() { return selectedConnectionDisconnectTargets(activeNode.value, selectedTreeNodesInVisibleOrder()); } @@ -246,6 +249,24 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu return connectionDisconnectTargets().some((target) => connectionStore.connectedIds.has(target.connectionId)); } + function connectionGroupDisconnectTargets(): string[] { + const node = activeNode.value; + if (node.type !== "connection-group") return []; + return connectionStore.connectionIdsInGroups([node.id]).filter((connectionId) => connectionStore.connectedIds.has(connectionId)); + } + + function connectionGroupDisconnectMenuLabel(): string { + return t("connectionGroup.closeConnections", { count: connectionGroupDisconnectTargets().length }); + } + + function canDisconnectConnectionGroup(): boolean { + return connectionGroupDisconnectTargets().length > 0; + } + + async function disconnectConnectionGroup() { + await disconnectConnectionIds(connectionGroupDisconnectTargets()); + } + /** "断开并忘记本次密码"是否可用:save_password=false 且当前已连接(本次运行期必已输入密码)。 */ function canForgetSessionCredential(): boolean { const node = activeNode.value; @@ -426,6 +447,9 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu disconnectConnection, connectionDisconnectMenuLabel, canDisconnectConnection, + connectionGroupDisconnectMenuLabel, + canDisconnectConnectionGroup, + disconnectConnectionGroup, canForgetSessionCredential, disconnectAndForgetConnectionPassword, cancelConnectionAttempt, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3f8fa81d46..6c9d26f385 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2324,6 +2324,7 @@ export default { selectedConnections: "Selected {count}", exitMultiSelect: "Exit selection", renameGroup: "Rename Group", + closeConnections: "Close Connections in Group ({count})", deleteGroup: "Delete Group", deleteSelectedGroups: "Delete {count} Selected Groups", moveToGroup: "Move to Group", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index bb5526f9bd..8c9fef979a 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2253,6 +2253,7 @@ export default withEnglishFallback({ createGroup: "Nuevo grupo", groupNamePlaceholder: "Nombre del grupo", renameGroup: "Renombrar grupo", + closeConnections: "Cerrar conexiones del grupo ({count})", deleteGroup: "Eliminar grupo", deleteSelectedGroups: "Eliminar {count} grupos seleccionados", moveToGroup: "Mover al grupo", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index ed485640f9..c9bc7aee53 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2251,6 +2251,7 @@ export default withEnglishFallback({ createGroup: "Nuovo Gruppo", groupNamePlaceholder: "Nome gruppo", renameGroup: "Rinomina Gruppo", + closeConnections: "Chiudi le connessioni del gruppo ({count})", deleteGroup: "Elimina Gruppo", deleteSelectedGroups: "Elimina {count} gruppi selezionati", moveToGroup: "Sposta nel Gruppo", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 22651830b1..59f1d937a4 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2278,6 +2278,7 @@ export default withEnglishFallback({ createGroup: "新しいグループ", groupNamePlaceholder: "グループ名", renameGroup: "グループ名を変更", + closeConnections: "グループ内の接続を閉じる({count})", deleteGroup: "グループを削除", deleteSelectedGroups: "選択した {count} 個のグループを削除", moveToGroup: "グループに移動", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 769d509c39..31f85d5d06 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2158,6 +2158,7 @@ export default withEnglishFallback({ selectedConnections: "{count}개 선택", exitMultiSelect: "선택 종료", renameGroup: "그룹 이름 변경", + closeConnections: "그룹 내 연결 {count}개 닫기", deleteGroup: "그룹 삭제", deleteSelectedGroups: "선택한 그룹 {count}개 삭제", moveToGroup: "그룹으로 이동", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 6d2a3787bb..03a676672c 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2253,6 +2253,7 @@ export default withEnglishFallback({ createGroup: "Novo Grupo", groupNamePlaceholder: "Nome do grupo", renameGroup: "Renomear Grupo", + closeConnections: "Fechar conexões do grupo ({count})", deleteGroup: "Excluir Grupo", deleteSelectedGroups: "Excluir {count} grupos selecionados", moveToGroup: "Mover para Grupo", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index fb80af0b86..397e0cc087 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2248,6 +2248,7 @@ export default withEnglishFallback({ selectedConnections: "已选 {count}", exitMultiSelect: "退出多选", renameGroup: "重命名分组", + closeConnections: "关闭分组内连接({count})", deleteGroup: "删除分组", deleteSelectedGroups: "删除选中的 {count} 个分组", moveToGroup: "移至分组", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0fc0813d5f..1c43cd0274 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2252,6 +2252,7 @@ export default withEnglishFallback({ createGroup: "建立群組", groupNamePlaceholder: "群組名稱", renameGroup: "重新命名群組", + closeConnections: "關閉群組內連線({count})", deleteGroup: "刪除群組", deleteSelectedGroups: "刪除選取的 {count} 個群組", moveToGroup: "移至群組",