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 <caisin@caisins-Mac-mini.local>
This commit is contained in:
Caisinandcaisin authored and GitHub committed 2026-06-07 16:58:49 +08:00
1 parent 74e19ec6a3
commit 13363b9094
10 files changed
+137 -19

No files matched your search

@@ -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[] {
<DialogTitle>{{ t("contextMenu.confirmDeleteTitle") }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">
{{ t("contextMenu.confirmDeleteMessage", { name: node.label }) }}
{{ connectionDeleteConfirmMessage() }}
</p>
<DialogFooter>
<Button variant="outline" @click="showDeleteConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
@@ -3393,7 +3415,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
showDeleteConfirm = false;
confirmDelete();
"
>{{ t("contextMenu.deleteConnection") }}</Button
>{{ connectionDeleteMenuLabel() }}</Button
>
</DialogFooter>
</DialogContent>
+4
View File
@@ -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",
+3
View File
@@ -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",
+4
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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: "复制连接",
+3
View File
@@ -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: "複製連線",
@@ -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];
}
+27 -8
View File
@@ -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<string>) {
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,