From 13363b9094bfadc055575ef4e4029a3da4db2bc1 Mon Sep 17 00:00:00 2001 From: Caisin Date: Sun, 7 Jun 2026 16:58:49 +0800 Subject: [PATCH] Enable safe multi-connection cleanup from the sidebar (#816) The sidebar already supports multi-select, so deletion now resolves the context-clicked selected connection set before confirming. Connection removal gained a batched path to persist once while pruning sidebar layout, pins, active state, connection errors, and stale selection for every removed connection. Constraint: No new dependencies and existing single-connection delete behavior must remain available.\nRejected: Delete every selected tree item regardless of type | mixed tree selections could unexpectedly remove connections while table/object deletion has separate SQL confirmation behavior.\nConfidence: high\nScope-risk: narrow\nTested: pnpm fmt; pnpm test; pnpm typecheck; pnpm lint; pnpm build\nNot-tested: Manual desktop UI interaction against a running Tauri window Co-authored-by: caisin --- .../src/components/sidebar/TreeItem.vue | 44 ++++++++++++++----- apps/desktop/src/i18n/locales/en.ts | 4 ++ apps/desktop/src/i18n/locales/es.ts | 3 ++ apps/desktop/src/i18n/locales/it.ts | 4 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 3 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 3 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 3 ++ .../src/lib/sidebarConnectionSelection.ts | 19 ++++++++ apps/desktop/src/stores/connectionStore.ts | 35 +++++++++++---- .../sidebarConnectionSelection.test.ts | 38 ++++++++++++++++ 10 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/lib/sidebarConnectionSelection.ts create mode 100644 packages/app-tests/sidebarConnectionSelection.test.ts diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index fd18588a2c..498b84bc90 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -131,6 +131,7 @@ import { treeSelectionRangeIdsByIndex, treeSelectionRangeIds, } from "@/lib/sidebarTreeSelection"; +import { selectedConnectionDeleteTargets } from "@/lib/sidebarConnectionSelection"; import { sidebarTreeContextKey } from "@/lib/sidebarTreeContext"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue"; @@ -904,20 +905,41 @@ async function refresh() { const showDeleteConfirm = ref(false); +function connectionDeleteTargets() { + return selectedConnectionDeleteTargets(props.node, selectedTreeNodesInVisibleOrder()); +} + +function connectionDeleteMenuLabel(): string { + const count = connectionDeleteTargets().length; + return count > 1 ? t("contextMenu.deleteSelectedConnections", { count }) : t("contextMenu.deleteConnection"); +} + +function connectionDeleteConfirmMessage(): string { + const targets = connectionDeleteTargets(); + return targets.length > 1 + ? t("contextMenu.confirmDeleteSelectedMessage", { count: targets.length }) + : t("contextMenu.confirmDeleteMessage", { name: props.node.label }); +} + function deleteConnection() { + if (!connectionDeleteTargets().length) return; showDeleteConfirm.value = true; } async function confirmDelete() { - const node = props.node; - if (node.connectionId) { - try { - await connectionStore.disconnect(node.connectionId); - await connectionStore.removeConnection(node.connectionId); - toast(t("connection.deleted"), 2000); - } catch (e: any) { - toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000); + const targets = connectionDeleteTargets(); + if (!targets.length) return; + try { + for (const target of targets) { + await connectionStore.disconnect(target.connectionId); } + await connectionStore.removeConnections(targets.map((target) => target.connectionId)); + toast( + targets.length > 1 ? t("connection.deletedSelected", { count: targets.length }) : t("connection.deleted"), + 2000, + ); + } catch (e: any) { + toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000); } } @@ -2913,7 +2935,7 @@ function treeItemMenuItems(): ContextMenuItem[] { items.push({ label: t("contextMenu.duplicateConnection"), action: duplicateConnection, icon: CopyPlus }); items.push({ label: "", separator: true }); items.push({ - label: t("contextMenu.deleteConnection"), + label: connectionDeleteMenuLabel(), action: deleteConnection, icon: Trash2, shortcut: shortcutDelete, @@ -3383,7 +3405,7 @@ function treeItemMenuItems(): ContextMenuItem[] { {{ t("contextMenu.confirmDeleteTitle") }}

- {{ t("contextMenu.confirmDeleteMessage", { name: node.label }) }} + {{ connectionDeleteConfirmMessage() }}

@@ -3393,7 +3415,7 @@ function treeItemMenuItems(): ContextMenuItem[] { showDeleteConfirm = false; confirmDelete(); " - >{{ t("contextMenu.deleteConnection") }}{{ connectionDeleteMenuLabel() }} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index e3483b53a2..fef4ce32a8 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -223,6 +223,7 @@ export default { clearError: "Clear connection error", saveFailed: "Failed to save connection: {message}", deleted: "Connection deleted", + deletedSelected: "{count} connections deleted", copied: "Copied", disconnected: "Disconnected", databaseConnectionClosed: "Closed database connection: {name}", @@ -939,8 +940,11 @@ export default { closeConnection: "Close Connection", closeDatabaseConnection: "Close Database Connection", deleteConnection: "Delete Connection", + deleteSelectedConnections: "Delete Selected {count} Connections", confirmDeleteTitle: "Delete Connection", confirmDeleteMessage: 'Are you sure you want to delete "{name}"? This cannot be undone.', + confirmDeleteSelectedMessage: + "Are you sure you want to delete the selected {count} connections? This cannot be undone.", editConnection: "Edit Connection", selectVisibleDatabases: "Select Visible Databases", duplicateConnection: "Duplicate Connection", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 8f07b5d9cb..8353208fd5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -215,6 +215,7 @@ export default { clearError: "Limpiar error de conexión", saveFailed: "Error al guardar la conexión: {message}", deleted: "Conexión eliminada", + deletedSelected: "{count} conexiones eliminadas", copied: "Copiado", disconnected: "Desconectado", databaseConnectionClosed: "Conexión de base de datos cerrada: {name}", @@ -834,8 +835,10 @@ export default { closeConnection: "Cerrar conexión", closeDatabaseConnection: "Cerrar conexión de base de datos", deleteConnection: "Eliminar conexión", + deleteSelectedConnections: "Eliminar {count} conexiones seleccionadas", confirmDeleteTitle: "Eliminar conexión", confirmDeleteMessage: '¿Estás seguro de que deseas eliminar "{name}"? Esta acción no se puede deshacer.', + confirmDeleteSelectedMessage: "¿Eliminar las {count} conexiones seleccionadas? Esta acción no se puede deshacer.", editConnection: "Editar conexión", selectVisibleDatabases: "Seleccionar bases visibles", duplicateConnection: "Duplicar conexión", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index befa75e25d..8da5646f38 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -226,6 +226,7 @@ export default { clearError: "Cancella errore di connessione", saveFailed: "Impossibile salvare la connessione: {message}", deleted: "Connessione eliminata", + deletedSelected: "{count} connessioni eliminate", copied: "Copiato", disconnected: "Disconnesso", databaseConnectionClosed: "Connessione al database chiusa: {name}", @@ -956,8 +957,11 @@ export default { closeConnection: "Chiudi Connessione", closeDatabaseConnection: "Chiudi Connessione Database", deleteConnection: "Elimina Connessione", + deleteSelectedConnections: "Elimina {count} connessioni selezionate", confirmDeleteTitle: "Elimina Connessione", confirmDeleteMessage: 'Sei sicuro di voler eliminare "{name}"? Questa azione non può essere annullata.', + confirmDeleteSelectedMessage: + "Eliminare le {count} connessioni selezionate? Questa azione non può essere annullata.", editConnection: "Modifica Connessione", selectVisibleDatabases: "Seleziona Database Visibili", duplicateConnection: "Duplica Connessione", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 611fa0ee92..257cc063b7 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -225,6 +225,7 @@ export default { clearError: "Limpar erro de conexão", saveFailed: "Falha ao salvar a conexão: {message}", deleted: "Conexão excluída", + deletedSelected: "{count} conexões excluídas", copied: "Copiado", disconnected: "Desconectado", databaseConnectionClosed: "Conexão de banco de dados fechada: {name}", @@ -952,8 +953,10 @@ export default { closeConnection: "Fechar Conexão", closeDatabaseConnection: "Fechar Conexão do Banco de Dados", deleteConnection: "Excluir Conexão", + deleteSelectedConnections: "Excluir {count} conexões selecionadas", confirmDeleteTitle: "Excluir Conexão", confirmDeleteMessage: 'Tem certeza de que deseja excluir "{name}"? Esta ação não pode ser desfeita.', + confirmDeleteSelectedMessage: "Excluir as {count} conexões selecionadas? Esta ação não pode ser desfeita.", editConnection: "Editar Conexão", selectVisibleDatabases: "Selecionar Bancos de Dados Visíveis", duplicateConnection: "Duplicar Conexão", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 14a9474ff8..c68783ef3d 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -219,6 +219,7 @@ export default { clearError: "清除连接错误", saveFailed: "保存连接失败:{message}", deleted: "连接已删除", + deletedSelected: "已删除 {count} 个连接", copied: "已复制", disconnected: "已断开连接", databaseConnectionClosed: "已关闭数据库连接:{name}", @@ -919,8 +920,10 @@ export default { closeConnection: "关闭连接", closeDatabaseConnection: "关闭数据库连接", deleteConnection: "删除连接", + deleteSelectedConnections: "删除选中的 {count} 个连接", confirmDeleteTitle: "删除连接", confirmDeleteMessage: "确定要删除「{name}」吗?此操作不可撤销。", + confirmDeleteSelectedMessage: "确定要删除选中的{count}个连接吗?", editConnection: "编辑连接", selectVisibleDatabases: "选择显示数据库", duplicateConnection: "复制连接", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3e55f0ee16..65b797cbb1 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -219,6 +219,7 @@ export default { clearError: "清除連線錯誤", saveFailed: "儲存連線失敗:{message}", deleted: "連線已刪除", + deletedSelected: "已刪除 {count} 個連線", copied: "已複製", disconnected: "已斷開連線", databaseConnectionClosed: "已關閉資料庫連線:{name}", @@ -897,8 +898,10 @@ export default { closeConnection: "關閉連線", closeDatabaseConnection: "關閉資料庫連線", deleteConnection: "刪除連線", + deleteSelectedConnections: "刪除選取的 {count} 個連線", confirmDeleteTitle: "刪除連線", confirmDeleteMessage: "確定要刪除「{name}」嗎?此操作無法復原。", + confirmDeleteSelectedMessage: "確定要刪除選取的{count}個連線嗎?", editConnection: "編輯連線", selectVisibleDatabases: "選擇顯示資料庫", duplicateConnection: "複製連線", diff --git a/apps/desktop/src/lib/sidebarConnectionSelection.ts b/apps/desktop/src/lib/sidebarConnectionSelection.ts new file mode 100644 index 0000000000..a0cdb5b1a0 --- /dev/null +++ b/apps/desktop/src/lib/sidebarConnectionSelection.ts @@ -0,0 +1,19 @@ +import type { TreeNode } from "@/types/database"; + +type ConnectionTreeNode = TreeNode & { connectionId: string }; + +function isConnectionNode(node: TreeNode): node is ConnectionTreeNode { + return node.type === "connection" && !!node.connectionId; +} + +export function selectedConnectionDeleteTargets( + currentNode: TreeNode, + selectedNodes: TreeNode[], +): ConnectionTreeNode[] { + if (!isConnectionNode(currentNode)) return []; + const selectedContainsCurrent = selectedNodes.some((node) => node.id === currentNode.id); + if (selectedNodes.length > 1 && selectedContainsCurrent && selectedNodes.every(isConnectionNode)) { + return selectedNodes; + } + return [currentNode]; +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 8342fe7fed..b21ea9fe60 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -584,21 +584,39 @@ export const useConnectionStore = defineStore("connection", () => { } } - async function removeConnection(id: string) { - const nextConnections = connections.value.filter((c) => c.id !== id); + async function removeConnections(ids: Iterable) { + const connectionIds = [...new Set(ids)].filter((id) => connections.value.some((c) => c.id === id)); + if (!connectionIds.length) return; + + const removedIds = new Set(connectionIds); + const nextConnections = connections.value.filter((c) => !removedIds.has(c.id)); await persistConnections(nextConnections); connections.value = nextConnections; - pinnedTreeNodeIds.value = prunePinnedTreeNodeIdsForConnection(pinnedTreeNodeIds.value, id); + for (const id of removedIds) { + pinnedTreeNodeIds.value = prunePinnedTreeNodeIdsForConnection(pinnedTreeNodeIds.value, id); + } persistPinnedTreeNodeIds(); - clearConnectionError(id); - sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id); + for (const id of removedIds) { + clearConnectionError(id); + connectedIds.value.delete(id); + sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id); + } rebuildTreeNodes(); persistSidebarLayoutDebounced(); - if (activeConnectionId.value === id) { + if (activeConnectionId.value && removedIds.has(activeConnectionId.value)) { activeConnectionId.value = null; } - invalidateCompletionCache(id); - clearLoadedChildrenCache(id); + selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => !removedIds.has(id)); + if (selectedTreeNodeId.value && removedIds.has(selectedTreeNodeId.value)) selectedTreeNodeId.value = null; + if (treeSelectionAnchorId.value && removedIds.has(treeSelectionAnchorId.value)) treeSelectionAnchorId.value = null; + for (const id of removedIds) { + invalidateCompletionCache(id); + clearLoadedChildrenCache(id); + } + } + + async function removeConnection(id: string) { + await removeConnections([id]); } async function updateConnection(config: ConnectionConfig) { @@ -2126,6 +2144,7 @@ export const useConnectionStore = defineStore("connection", () => { setVisibleDatabases, clearVisibleDatabases, removeConnection, + removeConnections, editingConnectionId, newConnectionGroupId, startEditing, diff --git a/packages/app-tests/sidebarConnectionSelection.test.ts b/packages/app-tests/sidebarConnectionSelection.test.ts new file mode 100644 index 0000000000..223bd1c3d4 --- /dev/null +++ b/packages/app-tests/sidebarConnectionSelection.test.ts @@ -0,0 +1,38 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; +import { selectedConnectionDeleteTargets } from "../../apps/desktop/src/lib/sidebarConnectionSelection.ts"; +import type { TreeNode } from "../../apps/desktop/src/types/database.ts"; + +function node(id: string, type: TreeNode["type"] = "connection"): TreeNode { + return { id, label: id, type, connectionId: type === "connection" ? id : "conn-1" }; +} + +test("deletes all selected connections when the context-clicked connection is selected", () => { + const first = node("conn-1"); + const second = node("conn-2"); + + assert.deepEqual( + selectedConnectionDeleteTargets(first, [first, second]).map((target) => target.connectionId), + ["conn-1", "conn-2"], + ); +}); + +test("falls back to only the current connection for mixed selections", () => { + const connection = node("conn-1"); + const table = node("table-1", "table"); + + assert.deepEqual( + selectedConnectionDeleteTargets(connection, [connection, table]).map((target) => target.connectionId), + ["conn-1"], + ); +}); + +test("falls back to only the current connection when right-clicking outside the selection", () => { + const current = node("conn-1"); + const selected = node("conn-2"); + + assert.deepEqual( + selectedConnectionDeleteTargets(current, [selected]).map((target) => target.connectionId), + ["conn-1"], + ); +});