feat(sidebar): support bulk connection group deletion

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-17 14:58:48 +08:00
1 parent 23102934ad
commit ce5b96189f
29 files changed
+864 -79

No files matched your search

@@ -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
@@ -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(
</DialogContent>
</Dialog>
<Dialog v-model:open="showDeleteGroupConfirm">
<DialogContent class="sm:max-w-[400px]">
<Dialog :open="showDeleteGroupConfirm" @update:open="updateDeleteGroupDialogOpen">
<DialogContent class="sm:max-w-[400px]" :show-close-button="!deletingConnectionGroups" @escape-key-down="preventDeleteGroupDialogDismiss" @interact-outside="preventDeleteGroupDialogDismiss">
<DialogHeader>
<DialogTitle>{{ t("connectionGroup.deleteGroupConfirmTitle") }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">
{{ t("connectionGroup.deleteGroupConfirmMessage", { name: node.label }) }}
{{ connectionGroupDeleteConfirmMessage() }}
</p>
<label class="flex items-start gap-2 rounded-md border border-border/70 px-3 py-2 text-sm" :class="deletingConnectionGroups ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'">
<input v-model="deleteConnectionsWithGroup" type="checkbox" class="mt-0.5 h-4 w-4 shrink-0 accent-primary" :disabled="deletingConnectionGroups" />
<span>{{ t("connectionGroup.deleteConnectionsWithGroup") }}</span>
</label>
<DialogFooter>
<Button variant="outline" @click="showDeleteGroupConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" @click="confirmDeleteGroup">{{ t("connectionGroup.deleteGroup") }}</Button>
<Button variant="outline" :disabled="deletingConnectionGroups" @click="showDeleteGroupConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" :disabled="deletingConnectionGroups" @click="confirmDeleteGroup">
<Loader2 v-if="deletingConnectionGroups" class="mr-2 h-4 w-4 animate-spin" />
{{ connectionGroupDeleteMenuLabel() }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -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,
@@ -530,6 +530,14 @@ function visibleTreeNodes(): TreeNode[] {
return flattenTree(connectionStore.treeNodes).map((item) => item.node);
}
function connectionIdsForSelection(): Set<string> {
return new Set(connectionStore.connections.map((connection) => connection.id));
}
function connectionGroupIdsForSelection(): Set<string> {
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) {
<Check v-if="isConnectionSelectionChecked" class="h-3 w-3 text-primary" />
<Square v-else class="h-3 w-3 stroke-[1.7]" />
</button>
<button
v-if="node.type === 'connection-group'"
type="button"
data-sidebar-group-selection-toggle="true"
class="ml-auto 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': isConnectionGroupSelectionChecked() || isConnectionGroupMultiSelectActive() }"
:aria-label="isConnectionGroupSelectionChecked() ? t('connectionGroup.deselectGroup') : t('connectionGroup.selectGroup')"
@mousedown.stop
@click="toggleConnectionGroupMultiSelection"
>
<Check v-if="isConnectionGroupSelectionChecked()" class="h-3 w-3 text-primary" />
<Square v-else class="h-3 w-3 stroke-[1.7]" />
</button>
</div>
<template v-if="detailTooltip" #content>
<div class="w-max min-w-40 max-w-[min(28rem,calc(100vw-24px))] rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-lg">
@@ -13,6 +13,65 @@ afterEach(() => {
});
describe("SidebarTreeItemDialogs MongoDB index form", () => {
it("keeps the group deletion dialog locked while deletion is running", async () => {
const controller = reactive({
node: { id: "group-parent", label: "Parent", type: "connection-group" },
t: (key: string) => key,
highlight: (value: string) => value,
showDeleteConfirm: false,
showMoveToNewGroupDialog: false,
showDeleteGroupConfirm: true,
deleteConnectionsWithGroup: true,
connectionGroupDeleteConfirmMessage: () => "confirm",
connectionGroupDeleteMenuLabel: () => "delete",
deletingConnectionGroups: true,
confirmDeleteGroup: vi.fn(),
showRenameObjectDialog: false,
showStructurePreviewDialog: false,
showStructureDocCopyDialog: false,
showDuplicateDialog: false,
showPasteDialog: false,
showCreateDatabaseDialog: false,
showCreateDatabasePreviewDialog: false,
showEditDatabasePropertiesDialog: false,
showCreateNacosNamespaceDialog: false,
showEditNacosNamespaceDialog: false,
showRenameMongoCollectionDialog: false,
showCloneMongoCollectionDialog: false,
showCreateMongoIndexDialog: false,
showMongoIndexManagerDialog: false,
showRedisDatabaseAliasDialog: false,
showCreateSchemaDialog: false,
showEditSchemaCommentDialog: false,
});
const app = createApp(
defineComponent({
setup: () => () => h(SidebarTreeItemDialogs, { controller }),
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(document.createElement("div"));
await nextTick();
const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
const checkbox = dialog?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox?.disabled).toBe(true);
expect(dialog?.querySelector('[data-slot="dialog-close"]')).toBeNull();
dialog?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
await nextTick();
expect(controller.showDeleteGroupConfirm).toBe(true);
controller.deletingConnectionGroups = false;
await nextTick();
expect(checkbox?.disabled).toBe(false);
const closeButton = dialog?.querySelector<HTMLButtonElement>('[data-slot="dialog-close"]');
expect(closeButton).not.toBeNull();
closeButton?.click();
await vi.waitFor(() => expect(controller.showDeleteGroupConfirm).toBe(false));
});
it("mounts the form and overlay from the same controlled dialog state", async () => {
const confirmCreateMongoIndex = vi.fn();
const closed = vi.fn();
@@ -6,7 +6,7 @@ import i18n from "@/i18n";
import TreeItem from "@/components/sidebar/TreeItem.vue";
import { filterSidebarTree } from "@/lib/sidebar/sidebarSearchTree";
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime";
import type { TreeNode } from "@/types/database";
import type { SidebarLayout, TreeNode } from "@/types/database";
const connectionStore = {
activeConnectionId: "connection-1",
@@ -26,6 +26,7 @@ const connectionStore = {
selectedTreeNodeId: null as string | null,
selectedTreeNodeIds: [] as string[],
selectedTreeNodeIdsSet: new Set<string>(),
sidebarLayout: { groups: [], order: [] } as SidebarLayout,
sidebarTableSearchQueries: {},
tableNameFilterForScope: () => undefined,
treeNodes: [],
@@ -101,6 +102,8 @@ afterEach(() => {
connectionStore.selectedTreeNodeId = null;
connectionStore.selectedTreeNodeIds = [];
connectionStore.selectedTreeNodeIdsSet = new Set<string>();
connectionStore.connectionMultiSelectActive = false;
connectionStore.sidebarLayout = { groups: [], order: [] };
connectionStore.treeNodes = [];
connectionStore.treeSelectionAnchorId = null;
settingsStore.editorSettings.sidebarActivation = "double";
@@ -108,6 +111,37 @@ afterEach(() => {
});
describe("TreeItem load-more activation", () => {
it("toggles a connection group through its row checkbox", async () => {
const group: TreeNode = {
id: "group-1",
label: "Group 1",
type: "connection-group",
children: [],
};
connectionStore.treeNodes = [group];
connectionStore.sidebarLayout = {
groups: [{ id: group.id, name: group.label, collapsed: false }],
order: [{ type: "group", id: group.id, children: [] }],
};
const { row } = await mountTreeItem(group);
const toggle = row.querySelector<HTMLButtonElement>('[data-sidebar-group-selection-toggle="true"]');
expect(toggle).not.toBeNull();
toggle?.click();
expect(connectionStore.selectedTreeNodeIds).toEqual([group.id]);
expect(connectionStore.selectedTreeNodeId).toBe(group.id);
expect(connectionStore.treeSelectionAnchorId).toBe(group.id);
expect(connectionStore.connectionMultiSelectActive).toBe(true);
toggle?.click();
expect(connectionStore.selectedTreeNodeIds).toEqual([]);
expect(connectionStore.selectedTreeNodeId).toBeNull();
expect(connectionStore.treeSelectionAnchorId).toBeNull();
expect(connectionStore.connectionMultiSelectActive).toBe(false);
});
it("runs load-more on a single click in double-click activation mode", async () => {
const node: TreeNode = {
id: "connection-1:app:dbo:__procedures:__load_more:200",
@@ -8,6 +8,7 @@ import type { MongoCreateIndexForm, MongoIndexRow } from "@/lib/sidebar/mongoCol
export type DuplicateStructureSource = TreeNode & { connectionId: string; database: string };
type ConnectionDeleteTarget = TreeNode & { connectionId: string };
type ConnectionGroupDeleteTarget = TreeNode & { type: "connection-group" };
export const fallbackCreateDatabaseCharset = fallbackCreateDatabaseCharsetMetadata();
@@ -15,6 +16,8 @@ export const sidebarTreeDialogOwner = shallowRef<symbol | null>(null);
export const sidebarDangerTarget = shallowRef<TreeNode | null>(null);
export const sidebarFormTarget = shallowRef<TreeNode | null>(null);
export const connectionDeleteTargetSnapshot = ref<ConnectionDeleteTarget[]>([]);
export const connectionGroupDeleteTargetSnapshot = ref<ConnectionGroupDeleteTarget[]>([]);
export const deleteConnectionsWithGroup = ref(false);
export const showDeleteConfirm = ref(false);
export const showDropTableConfirm = ref(false);
export const showDropTableChildObjectConfirm = ref(false);
@@ -229,4 +232,6 @@ export function resetSidebarTreeDialogState() {
sidebarDangerTarget.value = null;
sidebarFormTarget.value = null;
connectionDeleteTargetSnapshot.value = [];
connectionGroupDeleteTargetSnapshot.value = [];
deleteConnectionsWithGroup.value = false;
}
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { shallowRef } from "vue";
import { sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
import { connectionGroupDeleteTargetSnapshot, deleteConnectionsWithGroup, showDeleteGroupConfirm, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
import type { TreeNode } from "@/types/database";
const mocks = vi.hoisted(() => ({
@@ -30,6 +30,16 @@ function connectionNode(connectionId: string): TreeNode {
};
}
function connectionGroupNode(groupId: string): TreeNode {
return {
id: groupId,
label: groupId,
type: "connection-group",
isExpanded: true,
children: [],
};
}
function connectionStore(selectedTreeNodeIds: string[]) {
const lastSelectedTreeNodeId = selectedTreeNodeIds[selectedTreeNodeIds.length - 1] ?? null;
return {
@@ -39,6 +49,9 @@ function connectionStore(selectedTreeNodeIds: string[]) {
connectionMultiSelectActive: selectedTreeNodeIds.length > 0,
moveConnectionToGroup: vi.fn(),
createConnectionGroup: vi.fn(() => "group-new"),
connectionIdsInGroups: vi.fn((): string[] => []),
deleteConnectionGroups: vi.fn().mockResolvedValue([]),
removeConnections: vi.fn().mockResolvedValue(undefined),
connectedIds: new Set<string>(),
connectingIds: new Set<string>(),
disconnect: vi.fn().mockResolvedValue(undefined),
@@ -47,15 +60,14 @@ function connectionStore(selectedTreeNodeIds: string[]) {
};
}
function runtime(activeNode: TreeNode, store: ReturnType<typeof connectionStore>, selectedNodes: TreeNode[] = []) {
function runtime(activeNode: TreeNode, store: ReturnType<typeof connectionStore>, selectedNodes: TreeNode[] = [], requestGroupRename = vi.fn()) {
return useSidebarConnectionMutationRuntime({
activeNode: shallowRef(activeNode),
releaseActiveNodeReference: vi.fn(),
selectedTreeNodesInVisibleOrder: () => selectedNodes,
connectionStore: store as any,
queryStore: { openDatabaseKeys: new Set<string>() } as any,
requestGroupRename: vi.fn(),
groupCreated: vi.fn(),
requestGroupRename,
openVisibleDatabases: vi.fn(),
openVisibleSchemas: vi.fn(),
});
@@ -131,6 +143,78 @@ describe("sidebar connection move selection", () => {
});
});
describe("sidebar connection group deletion selection", () => {
beforeEach(() => {
vi.clearAllMocks();
sidebarFormTarget.value = null;
connectionGroupDeleteTargetSnapshot.value = [];
deleteConnectionsWithGroup.value = false;
showDeleteGroupConfirm.value = false;
});
it("snapshots every selected group for the confirmation dialog", () => {
const groups = [connectionGroupNode("group-1"), connectionGroupNode("group-2")];
const store = connectionStore(groups.map((group) => group.id));
const { connectionGroupDeleteConfirmMessage, connectionGroupDeleteMenuLabel, deleteConnectionGroup } = runtime(groups[1], store, groups);
expect(connectionGroupDeleteMenuLabel()).toBe('connectionGroup.deleteSelectedGroups:{"count":2}');
deleteConnectionGroup();
groups.splice(0);
expect(showDeleteGroupConfirm.value).toBe(true);
expect(connectionGroupDeleteTargetSnapshot.value.map((group) => group.id)).toEqual(["group-1", "group-2"]);
expect(connectionGroupDeleteConfirmMessage()).toBe('connectionGroup.deleteSelectedGroupsConfirmMessage:{"count":2}');
});
it("starts inline rename after creating a subgroup", () => {
const parent = connectionGroupNode("group-parent");
const store = connectionStore([parent.id]);
const requestGroupRename = vi.fn();
const { newSubgroup } = runtime(parent, store, [parent], requestGroupRename);
newSubgroup();
expect(store.createConnectionGroup).toHaveBeenCalledWith("connectionGroup.newGroupDefault", "group-parent");
expect(requestGroupRename).toHaveBeenCalledWith("group-new");
});
it("deletes nested connections and groups as one confirmed operation", async () => {
const groups = [connectionGroupNode("group-1"), connectionGroupNode("group-2")];
const store = connectionStore(groups.map((group) => group.id));
store.deleteConnectionGroups.mockResolvedValue(["conn-1", "conn-2"]);
const { confirmDeleteGroup, deleteConnectionGroup } = runtime(groups[0], store, groups);
deleteConnectionGroup();
deleteConnectionsWithGroup.value = true;
await confirmDeleteGroup();
expect(store.removeConnections).not.toHaveBeenCalled();
expect(store.deleteConnectionGroups).toHaveBeenCalledWith(["group-1", "group-2"], true);
expect(store.disconnect.mock.calls).toEqual([["conn-1"], ["conn-2"]]);
expect(showDeleteGroupConfirm.value).toBe(false);
expect(connectionGroupDeleteTargetSnapshot.value).toEqual([]);
expect(deleteConnectionsWithGroup.value).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith('connection.groupsDeleted:{"count":2}', 2000);
});
it("keeps the groups and dialog open when connection persistence fails", async () => {
const group = connectionGroupNode("group-1");
const store = connectionStore([group.id]);
store.deleteConnectionGroups.mockRejectedValue(new Error("persist failed"));
const { confirmDeleteGroup, deleteConnectionGroup } = runtime(group, store, [group]);
deleteConnectionGroup();
deleteConnectionsWithGroup.value = true;
await confirmDeleteGroup();
expect(store.deleteConnectionGroups).toHaveBeenCalledWith(["group-1"], true);
expect(store.disconnect).not.toHaveBeenCalled();
expect(showDeleteGroupConfirm.value).toBe(true);
expect(connectionGroupDeleteTargetSnapshot.value.map((target) => target.id)).toEqual(["group-1"]);
expect(mocks.toast).toHaveBeenCalledWith('connection.saveFailed:{"message":"persist failed"}', 5000);
});
});
describe("sidebar connection disconnect selection", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -1,4 +1,4 @@
import { computed, type ShallowRef } from "vue";
import { computed, ref, type ShallowRef } from "vue";
import { useI18n } from "vue-i18n";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -14,9 +14,9 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { revealPathInFileManager } from "@/lib/backend/tauri";
import { canConfigureVisibleSchemasForTreeNode } from "@/lib/database/databaseFeatureSupport";
import { canCloseSidebarDatabaseConnection } from "@/lib/sidebar/sidebarDatabaseOpenState";
import { selectedConnectionDeleteTargets, selectedConnectionDisconnectTargets, selectedConnectionDuplicateTargets } from "@/lib/sidebar/sidebarConnectionSelection";
import { selectedConnectionDeleteTargets, selectedConnectionDisconnectTargets, selectedConnectionDuplicateTargets, selectedConnectionGroupDeleteTargets } from "@/lib/sidebar/sidebarConnectionSelection";
import { releaseConnectionFromMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { connectionDeleteTargetSnapshot, showDeleteConfirm, showDeleteGroupConfirm, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
import { connectionDeleteTargetSnapshot, connectionGroupDeleteTargetSnapshot, deleteConnectionsWithGroup, showDeleteConfirm, showDeleteGroupConfirm, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
import { disconnectSidebarConnections } from "@/lib/sidebar/sidebarConnectionDisconnect";
@@ -27,7 +27,6 @@ interface SidebarConnectionMutationRuntimeOptions {
connectionStore: ReturnType<typeof useConnectionStore>;
queryStore: ReturnType<typeof useQueryStore>;
requestGroupRename: (groupId: string) => void;
groupCreated: (groupId: string) => void;
openVisibleDatabases: (node: TreeNode) => void;
openVisibleSchemas: (node: TreeNode) => void;
}
@@ -36,6 +35,7 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
const { t } = useI18n();
const { toast } = useToast();
const { activeNode, selectedTreeNodesInVisibleOrder, connectionStore, queryStore } = options;
const deletingConnectionGroups = ref(false);
async function setNodeAsDefaultDatabase() {
const node = activeNode.value;
@@ -324,7 +324,26 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
if (activeNode.value.type === "connection-group") options.requestGroupRename(activeNode.value.id);
}
function connectionGroupDeleteTargets() {
if (showDeleteGroupConfirm.value && connectionGroupDeleteTargetSnapshot.value.length) return connectionGroupDeleteTargetSnapshot.value;
return selectedConnectionGroupDeleteTargets(activeNode.value, selectedTreeNodesInVisibleOrder());
}
function connectionGroupDeleteMenuLabel(): string {
const count = connectionGroupDeleteTargets().length;
return count > 1 ? t("connectionGroup.deleteSelectedGroups", { count }) : t("connectionGroup.deleteGroup");
}
function connectionGroupDeleteConfirmMessage(): string {
const targets = connectionGroupDeleteTargets();
return targets.length > 1 ? t("connectionGroup.deleteSelectedGroupsConfirmMessage", { count: targets.length }) : t("connectionGroup.deleteGroupConfirmMessage", { name: targets[0]?.label || sidebarFormTarget.value?.label || activeNode.value.label });
}
function deleteConnectionGroup() {
const targets = selectedConnectionGroupDeleteTargets(activeNode.value, selectedTreeNodesInVisibleOrder());
if (!targets.length) return;
connectionGroupDeleteTargetSnapshot.value = targets.slice();
deleteConnectionsWithGroup.value = false;
showDeleteGroupConfirm.value = true;
}
@@ -334,16 +353,32 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
function newSubgroup() {
const groupId = connectionStore.createConnectionGroup(t("connectionGroup.newGroupDefault"), activeNode.value.id);
connectionStore.selectedTreeNodeId = groupId;
options.groupCreated(groupId);
options.requestGroupRename(groupId);
}
function confirmDeleteGroup() {
const node = sidebarFormTarget.value ?? activeNode.value;
connectionStore.deleteConnectionGroup(node.id);
options.releaseActiveNodeReference([node.id]);
showDeleteGroupConfirm.value = false;
toast(t("connection.groupDeleted"), 2000);
async function confirmDeleteGroup() {
const targets = connectionGroupDeleteTargets();
if (!targets.length || deletingConnectionGroups.value) return;
const groupIds = targets.map((target) => target.id);
deletingConnectionGroups.value = true;
try {
const connectionIds = await connectionStore.deleteConnectionGroups(groupIds, deleteConnectionsWithGroup.value);
options.releaseActiveNodeReference(groupIds);
for (const connectionId of connectionIds) {
connectionStore.disconnect(connectionId).catch((error) => {
console.warn("[DBX][connection-group:delete:disconnect-failed]", { connectionId, error });
});
}
showDeleteGroupConfirm.value = false;
connectionGroupDeleteTargetSnapshot.value = [];
deleteConnectionsWithGroup.value = false;
toast(targets.length > 1 ? t("connection.groupsDeleted", { count: targets.length }) : t("connection.groupDeleted"), 2000);
} catch (error: any) {
toast(t("connection.saveFailed", { message: error?.message || String(error) }), 5000);
} finally {
deletingConnectionGroups.value = false;
}
}
function moveToGroup(groupId: string | null) {
@@ -403,10 +438,14 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
openVisibleDatabasesDialog,
openVisibleSchemasDialog,
startRenameGroup,
connectionGroupDeleteTargets,
connectionGroupDeleteMenuLabel,
connectionGroupDeleteConfirmMessage,
deleteConnectionGroup,
newConnectionInGroup,
newSubgroup,
confirmDeleteGroup,
deletingConnectionGroups,
moveToGroup,
createGroupAndMoveConnection,
};
+7 -1
View File
@@ -1001,6 +1001,7 @@ export default {
duplicated: "Connection duplicated",
duplicatedSelected: "{count} connections duplicated",
groupDeleted: "Group deleted",
groupsDeleted: "{count} groups deleted",
loadFailed: "Failed to load saved connections: {message}",
sshTunnel: "Tunnel / Proxy",
advancedTab: "Advanced",
@@ -2238,6 +2239,7 @@ export default {
exitMultiSelect: "Exit selection",
renameGroup: "Rename Group",
deleteGroup: "Delete Group",
deleteSelectedGroups: "Delete {count} Selected Groups",
moveToGroup: "Move to Group",
moveToNewGroup: "Move to New Group",
ungrouped: "Ungrouped",
@@ -2245,7 +2247,11 @@ export default {
newGroup: "New Group...",
newGroupDefault: "New Group",
deleteGroupConfirmTitle: "Delete Group",
deleteGroupConfirmMessage: 'Delete group "{name}"? Connections inside will be moved to the top level.',
deleteGroupConfirmMessage: 'Delete group "{name}" and all of its subgroups? Choose below whether to also delete the connections inside.',
deleteSelectedGroupsConfirmMessage: "Delete {count} selected groups and all of their subgroups? Choose below whether to also delete the connections inside.",
deleteConnectionsWithGroup: "Also delete all connections inside the selected groups and their subgroups",
selectGroup: "Select group",
deselectGroup: "Deselect group",
},
databaseSearch: {
title: "Search Database",
+7 -1
View File
@@ -795,6 +795,7 @@ export default withEnglishFallback({
duplicated: "Conexión duplicada",
duplicatedSelected: "{count} conexiones duplicadas",
groupDeleted: "Grupo eliminado",
groupsDeleted: "Se eliminaron {count} grupos",
loadFailed: "Error al cargar las conexiones guardadas: {message}",
sshTunnel: "Túnel SSH / Proxy",
advancedTab: "Avanzado",
@@ -2167,6 +2168,7 @@ export default withEnglishFallback({
groupNamePlaceholder: "Nombre del grupo",
renameGroup: "Renombrar grupo",
deleteGroup: "Eliminar grupo",
deleteSelectedGroups: "Eliminar {count} grupos seleccionados",
moveToGroup: "Mover al grupo",
moveToNewGroup: "Mover a nuevo grupo",
ungrouped: "Sin grupo",
@@ -2174,7 +2176,11 @@ export default withEnglishFallback({
newGroup: "Nuevo grupo...",
newGroupDefault: "Nuevo grupo",
deleteGroupConfirmTitle: "Eliminar grupo",
deleteGroupConfirmMessage: '¿Eliminar el grupo "{name}"? Las conexiones dentro serán movidas al nivel superior.',
deleteGroupConfirmMessage: '¿Eliminar el grupo "{name}" y todos sus subgrupos? Elige abajo si también deseas eliminar las conexiones.',
deleteSelectedGroupsConfirmMessage: "¿Eliminar {count} grupos seleccionados y todos sus subgrupos? Elige abajo si también deseas eliminar las conexiones.",
deleteConnectionsWithGroup: "Eliminar también todas las conexiones de los grupos seleccionados y sus subgrupos",
selectGroup: "Seleccionar grupo",
deselectGroup: "Deseleccionar grupo",
selectConnection: "Seleccionar conexión",
deselectConnection: "Deseleccionar conexión",
selectAllConnections: "Seleccionar todas las conexiones",
+7 -1
View File
@@ -793,6 +793,7 @@ export default withEnglishFallback({
duplicated: "Connessione duplicata",
duplicatedSelected: "{count} connessioni duplicate",
groupDeleted: "Gruppo eliminato",
groupsDeleted: "Eliminati {count} gruppi",
loadFailed: "Impossibile caricare le connessioni salvate: {message}",
sshTunnel: "Tunnel SSH / Proxy",
advancedTab: "Avanzate",
@@ -2165,6 +2166,7 @@ export default withEnglishFallback({
groupNamePlaceholder: "Nome gruppo",
renameGroup: "Rinomina Gruppo",
deleteGroup: "Elimina Gruppo",
deleteSelectedGroups: "Elimina {count} gruppi selezionati",
moveToGroup: "Sposta nel Gruppo",
moveToNewGroup: "Sposta in un Nuovo Gruppo",
ungrouped: "Non raggruppati",
@@ -2172,7 +2174,11 @@ export default withEnglishFallback({
newGroup: "Nuovo Gruppo...",
newGroupDefault: "Nuovo Gruppo",
deleteGroupConfirmTitle: "Elimina Gruppo",
deleteGroupConfirmMessage: 'Eliminare il gruppo "{name}"? Le connessioni al suo interno verranno spostate al livello principale.',
deleteGroupConfirmMessage: 'Eliminare il gruppo "{name}" e tutti i suoi sottogruppi? Scegli qui sotto se eliminare anche le connessioni.',
deleteSelectedGroupsConfirmMessage: "Eliminare {count} gruppi selezionati e tutti i relativi sottogruppi? Scegli qui sotto se eliminare anche le connessioni.",
deleteConnectionsWithGroup: "Elimina anche tutte le connessioni nei gruppi selezionati e nei relativi sottogruppi",
selectGroup: "Seleziona gruppo",
deselectGroup: "Deseleziona gruppo",
selectConnection: "Seleziona connessione",
deselectConnection: "Deseleziona connessione",
selectAllConnections: "Seleziona tutte le connessioni",
+7 -1
View File
@@ -759,6 +759,7 @@ export default withEnglishFallback({
duplicated: "接続を複製しました",
duplicatedSelected: "{count}件の接続を複製しました",
groupDeleted: "グループを削除しました",
groupsDeleted: "{count} 個のグループを削除しました",
loadFailed: "保存された接続の読み込みに失敗しました: {message}",
sshTunnel: "SSHトンネル / プロキシ",
advancedTab: "詳細",
@@ -2192,6 +2193,7 @@ export default withEnglishFallback({
groupNamePlaceholder: "グループ名",
renameGroup: "グループ名を変更",
deleteGroup: "グループを削除",
deleteSelectedGroups: "選択した {count} 個のグループを削除",
moveToGroup: "グループに移動",
moveToNewGroup: "新しいグループに移動",
ungrouped: "グループなし",
@@ -2199,7 +2201,11 @@ export default withEnglishFallback({
newGroup: "新しいグループ...",
newGroupDefault: "新しいグループ",
deleteGroupConfirmTitle: "グループを削除",
deleteGroupConfirmMessage: "グループ「{name}」を削除しますか?グループ内の接続はトップレベルに移動されます。",
deleteGroupConfirmMessage: "グループ「{name}」とそのすべてのサブグループを削除しますか?接続も削除するかを下で選択してください。",
deleteSelectedGroupsConfirmMessage: "選択した {count} 個のグループとそのすべてのサブグループを削除しますか?接続も削除するかを下で選択してください。",
deleteConnectionsWithGroup: "選択したグループとサブグループ内の接続もすべて削除する",
selectGroup: "グループを選択",
deselectGroup: "グループの選択を解除",
selectConnection: "接続を選択",
deselectConnection: "接続の選択を解除",
selectAllConnections: "すべての接続を選択",
+7 -1
View File
@@ -893,6 +893,7 @@ export default withEnglishFallback({
duplicated: "연결을 복제했습니다",
duplicatedSelected: "연결 {count}개 복제",
groupDeleted: "그룹을 삭제했습니다",
groupsDeleted: "그룹 {count}개를 삭제했습니다",
loadFailed: "저장된 연결을 불러오는 데 실패했습니다: {message}",
sshTunnel: "터널 / 프록시",
advancedTab: "고급",
@@ -2091,6 +2092,7 @@ export default withEnglishFallback({
exitMultiSelect: "선택 종료",
renameGroup: "그룹 이름 변경",
deleteGroup: "그룹 삭제",
deleteSelectedGroups: "선택한 그룹 {count}개 삭제",
moveToGroup: "그룹으로 이동",
moveToNewGroup: "새 그룹으로 이동",
ungrouped: "그룹 없음",
@@ -2098,7 +2100,11 @@ export default withEnglishFallback({
newGroup: "새 그룹...",
newGroupDefault: "새 그룹",
deleteGroupConfirmTitle: "그룹 삭제",
deleteGroupConfirmMessage: '그룹 "{name}"을(를) 삭제하시겠습니까? 안의 연결은 최상위로 이동합니다.',
deleteGroupConfirmMessage: '그룹 "{name}"과(와) 모든 하위 그룹을 삭제하시겠습니까? 연결도 삭제할지는 아래에서 선택하세요.',
deleteSelectedGroupsConfirmMessage: "선택한 그룹 {count}개와 모든 하위 그룹을 삭제하시겠습니까? 연결도 삭제할지는 아래에서 선택하세요.",
deleteConnectionsWithGroup: "선택한 그룹과 하위 그룹의 모든 연결도 삭제",
selectGroup: "그룹 선택",
deselectGroup: "그룹 선택 해제",
},
databaseSearch: {
title: "데이터베이스 검색",
+7 -1
View File
@@ -794,6 +794,7 @@ export default withEnglishFallback({
duplicated: "Conexão duplicada",
duplicatedSelected: "{count} conexões duplicadas",
groupDeleted: "Grupo excluído",
groupsDeleted: "{count} grupos excluídos",
loadFailed: "Falha ao carregar as conexões salvas: {message}",
sshTunnel: "Túnel SSH / Proxy",
advancedTab: "Avançado",
@@ -2167,6 +2168,7 @@ export default withEnglishFallback({
groupNamePlaceholder: "Nome do grupo",
renameGroup: "Renomear Grupo",
deleteGroup: "Excluir Grupo",
deleteSelectedGroups: "Excluir {count} grupos selecionados",
moveToGroup: "Mover para Grupo",
moveToNewGroup: "Mover para Novo Grupo",
ungrouped: "Sem grupo",
@@ -2174,7 +2176,11 @@ export default withEnglishFallback({
newGroup: "Novo Grupo...",
newGroupDefault: "Novo Grupo",
deleteGroupConfirmTitle: "Excluir Grupo",
deleteGroupConfirmMessage: 'Excluir o grupo "{name}"? As conexões dentro dele serão movidas para o nível superior.',
deleteGroupConfirmMessage: 'Excluir o grupo "{name}" e todos os seus subgrupos? Escolha abaixo se também deseja excluir as conexões.',
deleteSelectedGroupsConfirmMessage: "Excluir {count} grupos selecionados e todos os seus subgrupos? Escolha abaixo se também deseja excluir as conexões.",
deleteConnectionsWithGroup: "Excluir também todas as conexões dos grupos selecionados e seus subgrupos",
selectGroup: "Selecionar grupo",
deselectGroup: "Desmarcar grupo",
selectConnection: "Selecionar conexão",
deselectConnection: "Desmarcar conexão",
selectAllConnections: "Selecionar todas as conexões",
+7 -1
View File
@@ -928,6 +928,7 @@ export default withEnglishFallback({
duplicated: "连接已复制",
duplicatedSelected: "已复制 {count} 个连接",
groupDeleted: "分组已删除",
groupsDeleted: "已删除 {count} 个分组",
loadFailed: "加载已保存连接失败:{message}",
sshTunnel: "隧道/代理",
advancedTab: "高级",
@@ -2162,6 +2163,7 @@ export default withEnglishFallback({
exitMultiSelect: "退出多选",
renameGroup: "重命名分组",
deleteGroup: "删除分组",
deleteSelectedGroups: "删除选中的 {count} 个分组",
moveToGroup: "移至分组",
moveToNewGroup: "移至新分组",
ungrouped: "取消分组",
@@ -2169,7 +2171,11 @@ export default withEnglishFallback({
newGroup: "新建分组...",
newGroupDefault: "新分组",
deleteGroupConfirmTitle: "删除分组",
deleteGroupConfirmMessage: "删除分组「{name}」?其中的连接将移到顶层。",
deleteGroupConfirmMessage: "删除分组「{name}」及其所有子分组?请在下方选择是否同时删除其中的连接。",
deleteSelectedGroupsConfirmMessage: "删除选中的 {count} 个分组及其所有子分组?请在下方选择是否同时删除其中的连接。",
deleteConnectionsWithGroup: "同时删除所选分组及其子分组中的所有连接",
selectGroup: "选择分组",
deselectGroup: "取消选择分组",
},
databaseSearch: {
title: "在数据库中查找",
+7 -1
View File
@@ -793,6 +793,7 @@ export default withEnglishFallback({
duplicated: "連線已複製",
duplicatedSelected: "已複製 {count} 個連線",
groupDeleted: "群組已刪除",
groupsDeleted: "已刪除 {count} 個群組",
loadFailed: "載入已儲存連線失敗:{message}",
sshTunnel: "SSH 隧道/代理",
advancedTab: "進階",
@@ -2166,6 +2167,7 @@ export default withEnglishFallback({
groupNamePlaceholder: "群組名稱",
renameGroup: "重新命名群組",
deleteGroup: "刪除群組",
deleteSelectedGroups: "刪除選取的 {count} 個群組",
moveToGroup: "移至群組",
moveToNewGroup: "移至新群組",
ungrouped: "取消群組",
@@ -2173,7 +2175,11 @@ export default withEnglishFallback({
newGroup: "新增群組……",
newGroupDefault: "新群組",
deleteGroupConfirmTitle: "刪除群組",
deleteGroupConfirmMessage: "刪除群組「{name}」?其中的連線將移到頂層。",
deleteGroupConfirmMessage: "刪除群組「{name}」及其所有子群組?請在下方選擇是否同時刪除其中的連線。",
deleteSelectedGroupsConfirmMessage: "刪除選取的 {count} 個群組及其所有子群組?請在下方選擇是否同時刪除其中的連線。",
deleteConnectionsWithGroup: "同時刪除所選群組及其子群組中的所有連線",
selectGroup: "選擇群組",
deselectGroup: "取消選擇群組",
selectConnection: "選擇連接",
deselectConnection: "取消選擇連接",
selectAllConnections: "全選連接",
@@ -73,6 +73,24 @@ describe("connection multi-selection", () => {
expect(store.connectionMultiSelectActive).toBe(true);
});
it("enables checkbox mode for a modifier-key selection containing only connection groups", () => {
const store = target();
applyTreeNodeSelection(store, { nodeIds: ["group-1", "group-2"], activeNodeId: "group-2", anchorNodeId: "group-2" }, new Set(["conn-1"]), new Set(["group-1", "group-2"]));
expect(store.selectedTreeNodeIds).toEqual(["group-1", "group-2"]);
expect(store.connectionMultiSelectActive).toBe(true);
});
it("keeps mixed connection and group selections out of checkbox mode", () => {
const store = target();
applyTreeNodeSelection(store, { nodeIds: ["conn-1", "group-1"], activeNodeId: "group-1", anchorNodeId: "conn-1" }, new Set(["conn-1"]), new Set(["group-1"]));
expect(store.selectedTreeNodeIds).toEqual(["conn-1", "group-1"]);
expect(store.connectionMultiSelectActive).toBe(false);
});
it("keeps mixed modifier-key selections out of connection checkbox mode", () => {
const store = target();
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { copySelectedConnectionsToClipboards, selectedConnectionDeleteTargets, selectedConnectionDisconnectTargets, selectedConnectionDuplicateTargets } from "@/lib/sidebar/sidebarConnectionSelection";
import { copySelectedConnectionsToClipboards, selectedConnectionDeleteTargets, selectedConnectionDisconnectTargets, selectedConnectionDuplicateTargets, selectedConnectionGroupDeleteTargets } from "@/lib/sidebar/sidebarConnectionSelection";
import type { TreeNode } from "@/types/database";
function node(id: string, type: TreeNode["type"] = "connection"): TreeNode {
@@ -59,3 +59,21 @@ describe("sidebar connection selection", () => {
expect(copyConnectionsToTreeClipboard).toHaveBeenCalledWith(["conn-1"]);
});
});
describe("sidebar connection group selection", () => {
it("uses all selected groups when the current group is selected", () => {
const current = node("group-2", "connection-group");
const selected = [node("group-1", "connection-group"), current];
expect(selectedConnectionGroupDeleteTargets(current, selected)).toEqual(selected);
});
it("falls back to the current group for mixed, stale, and single selections", () => {
const current = node("group-current", "connection-group");
const other = node("group-other", "connection-group");
expect(selectedConnectionGroupDeleteTargets(current, [other])).toEqual([current]);
expect(selectedConnectionGroupDeleteTargets(current, [current, node("conn-1")])).toEqual([current]);
expect(selectedConnectionGroupDeleteTargets(current, [current])).toEqual([current]);
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
import { connectionIdsInGroups, deleteGroups, findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
import type { SidebarLayout } from "@/types/database";
const layout: SidebarLayout = {
@@ -35,3 +35,50 @@ describe("findConnectionGroupPath", () => {
expect(findConnectionGroupPath(layout, "missing")).toBeNull();
});
});
describe("connection group deletion", () => {
it("collects nested connection ids once for overlapping selected groups", () => {
expect(connectionIdsInGroups(layout, ["project", "staging"])).toEqual(["nested", "grouped"]);
expect(connectionIdsInGroups(layout, ["staging"])).toEqual(["nested"]);
expect(connectionIdsInGroups(layout, ["missing"])).toEqual([]);
});
it("removes the selected group subtree while promoting all of its connections", () => {
expect(deleteGroups(layout, ["project"])).toEqual({
groups: [],
order: [
{ type: "connection", id: "nested" },
{ type: "connection", id: "grouped" },
{ type: "connection", id: "root" },
],
});
});
it("keeps the parent group when only a nested group subtree is deleted", () => {
expect(deleteGroups(layout, ["staging"])).toEqual({
groups: [{ id: "project", name: "Project", collapsed: false }],
order: [
{
type: "group",
id: "project",
children: [
{ type: "connection", id: "nested" },
{ type: "connection", id: "grouped" },
],
},
{ type: "connection", id: "root" },
],
});
});
it("deduplicates overlapping parent and child group deletions", () => {
expect(deleteGroups(layout, ["project", "staging"])).toEqual({
groups: [],
order: [
{ type: "connection", id: "nested" },
{ type: "connection", id: "grouped" },
{ type: "connection", id: "root" },
],
});
});
});
@@ -62,15 +62,17 @@ export function applyConnectionMultiSelection(target: ConnectionMultiSelectionTa
}
/**
* Keep modifier-key connection selection aligned with the checkbox mode. Mixed
* tree selections remain regular tree selections because connection-only bulk
* actions must never consume database or object nodes.
* Keep modifier-key connection and connection-group selection aligned with the
* checkbox mode. Mixed tree selections remain regular tree selections because
* type-specific bulk actions must never consume nodes of another kind.
*/
export function applyTreeNodeSelection(target: ConnectionMultiSelectionTarget, selection: TreeNodeSelection, connectionIds: ReadonlySet<string>): void {
export function applyTreeNodeSelection(target: ConnectionMultiSelectionTarget, selection: TreeNodeSelection, connectionIds: ReadonlySet<string>, connectionGroupIds: ReadonlySet<string> = new Set()): void {
target.selectedTreeNodeIds = selection.nodeIds;
target.selectedTreeNodeId = selection.activeNodeId;
target.treeSelectionAnchorId = selection.anchorNodeId;
target.connectionMultiSelectActive = selection.nodeIds.length > 0 && selection.nodeIds.every((id) => connectionIds.has(id));
const containsOnlyConnections = selection.nodeIds.every((id) => connectionIds.has(id));
const containsOnlyConnectionGroups = selection.nodeIds.every((id) => connectionGroupIds.has(id));
target.connectionMultiSelectActive = selection.nodeIds.length > 0 && (containsOnlyConnections || containsOnlyConnectionGroups);
}
export function releaseConnectionFromMultiSelection(target: ConnectionMultiSelectionTarget, connectionId: string): void {
@@ -2,11 +2,16 @@ import type { TreeNode } from "@/types/database";
import { copyNameForTreeNode } from "@/lib/sidebar/treeNodeClick";
type ConnectionTreeNode = TreeNode & { connectionId: string };
type ConnectionGroupTreeNode = TreeNode & { type: "connection-group" };
export function isConnectionNode(node: TreeNode): node is ConnectionTreeNode {
return node.type === "connection" && !!node.connectionId;
}
export function isConnectionGroupNode(node: TreeNode): node is ConnectionGroupTreeNode {
return node.type === "connection-group";
}
function selectedConnectionActionTargets(currentNode: TreeNode, selectedNodes: TreeNode[]): ConnectionTreeNode[] {
if (!isConnectionNode(currentNode)) return [];
const selectedContainsCurrent = selectedNodes.some((node) => node.id === currentNode.id);
@@ -24,6 +29,15 @@ export function selectedConnectionDuplicateTargets(currentNode: TreeNode, select
return selectedConnectionActionTargets(currentNode, selectedNodes);
}
export function selectedConnectionGroupDeleteTargets(currentNode: TreeNode, selectedNodes: TreeNode[]): ConnectionGroupTreeNode[] {
if (!isConnectionGroupNode(currentNode)) return [];
const selectedContainsCurrent = selectedNodes.some((node) => node.id === currentNode.id);
if (selectedNodes.length > 1 && selectedContainsCurrent && selectedNodes.every(isConnectionGroupNode)) {
return selectedNodes;
}
return [currentNode];
}
export function selectedConnectionDisconnectTargets(currentNode: TreeNode, selectedNodes: TreeNode[]): ConnectionTreeNode[] {
return selectedConnectionActionTargets(currentNode, selectedNodes);
}
+49 -14
View File
@@ -422,30 +422,65 @@ export function renameGroup(layout: SidebarLayout, groupId: string, name: string
};
}
export function deleteGroup(layout: SidebarLayout, groupId: string): SidebarLayout {
const order = cloneEntries(layout.order);
const removeGroup = (entries: SidebarOrderEntry[]): boolean => {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.type === "group" && entry.id === groupId) {
entries.splice(i, 1, ...entryChildren(entry));
return true;
}
export function connectionIdsInGroups(layout: SidebarLayout, groupIds: Iterable<string>): string[] {
const targets = new Set(groupIds);
if (!targets.size) return [];
const connectionIds: string[] = [];
const seenConnectionIds = new Set<string>();
const collectConnections = (entries: SidebarOrderEntry[]) => {
for (const entry of entries) {
if (entry.type === "group") {
const removed = removeGroup(entry.children ?? []);
if (removed) return true;
collectConnections(entryChildren(entry));
} else if (!seenConnectionIds.has(entry.id)) {
seenConnectionIds.add(entry.id);
connectionIds.push(entry.id);
}
}
return false;
};
const visit = (entries: SidebarOrderEntry[]) => {
for (const entry of entries) {
if (entry.type !== "group") continue;
if (targets.has(entry.id)) collectConnections(entryChildren(entry));
else visit(entryChildren(entry));
}
};
visit(layout.order);
return connectionIds;
}
const removed = removeGroup(order);
export function deleteGroups(layout: SidebarLayout, groupIds: Iterable<string>): SidebarLayout {
const targets = new Set(groupIds);
if (!targets.size) return layout;
const removedGroupIds = new Set<string>();
const flattenDeletedGroup = (entry: Extract<SidebarOrderEntry, { type: "group" }>): SidebarOrderEntry[] => {
removedGroupIds.add(entry.id);
return entryChildren(entry).flatMap((child): SidebarOrderEntry[] => {
if (child.type === "connection") return [{ ...child }];
return flattenDeletedGroup(child);
});
};
const removeGroups = (entries: SidebarOrderEntry[]): SidebarOrderEntry[] =>
entries.flatMap((entry): SidebarOrderEntry[] => {
if (entry.type === "connection") return [{ ...entry }];
if (targets.has(entry.id)) return flattenDeletedGroup(entry);
const children = removeGroups(entryChildren(entry));
return [{ type: "group", id: entry.id, children }];
});
const order = removeGroups(layout.order);
if (!removedGroupIds.size) return layout;
return {
groups: removed ? layout.groups.filter((group) => group.id !== groupId) : layout.groups,
groups: layout.groups.filter((group) => !removedGroupIds.has(group.id)),
order,
};
}
export function deleteGroup(layout: SidebarLayout, groupId: string): SidebarLayout {
return deleteGroups(layout, [groupId]);
}
export function toggleGroupCollapsed(layout: SidebarLayout, groupId: string): SidebarLayout {
return {
...layout,
@@ -1,5 +1,11 @@
import type { TreeNode } from "@/types/database";
export type VisibleTreeSelection = {
nodeIds: readonly string[];
activeNodeId: string | null;
anchorNodeId: string | null;
};
export function supportsSidebarModifierSelection(node: Pick<TreeNode, "type">): boolean {
return node.type !== "database" && node.type !== "schema" && !node.type.startsWith("group-");
}
@@ -15,6 +21,14 @@ export function selectedTreeNodesInVisibleOrder(visibleNodes: TreeNode[], select
return visibleNodes.filter((node) => ids.has(node.id));
}
export function pruneTreeSelectionToVisibleNodes(visibleNodes: readonly TreeNode[], selection: VisibleTreeSelection): VisibleTreeSelection {
const visibleIds = new Set(visibleNodes.map((node) => node.id));
const nodeIds = selection.nodeIds.filter((id) => visibleIds.has(id));
const activeNodeId = selection.activeNodeId && visibleIds.has(selection.activeNodeId) ? selection.activeNodeId : (nodeIds[0] ?? null);
const anchorNodeId = selection.anchorNodeId && visibleIds.has(selection.anchorNodeId) ? selection.anchorNodeId : activeNodeId;
return { nodeIds, activeNodeId, anchorNodeId };
}
export function selectedSidebarBatchTargets(activeNode: TreeNode, selectedNodes: readonly TreeNode[], canUseTarget: (node: TreeNode) => boolean): TreeNode[] {
if (selectedNodes.length <= 1 || !selectedNodes.some((node) => node.id === activeNode.id)) return [];
const first = selectedNodes[0];
@@ -1,6 +1,6 @@
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig } from "@/types/database";
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
@@ -106,6 +106,27 @@ describe("connectionStore one_time runtime cleanup", () => {
expect(queryStore.tabs.some((tab) => tab.connectionId === "preview-1")).toBe(false);
});
it("deleteConnectionGroups closes the tabs of removed one_time connections", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useQueryStore } = await import("@/stores/queryStore");
const store = useConnectionStore();
const queryStore = useQueryStore();
const layout: SidebarLayout = {
groups: [{ id: "group-1", name: "Group", collapsed: false }],
order: [{ type: "group", id: "group-1", children: [{ type: "connection", id: "preview-1" }] }],
};
store.connections = [previewConnection()];
store.sidebarLayout = layout;
queryStore.createTab("preview-1", "", "sales.parquet", "query");
await store.deleteConnectionGroups(["group-1"], true);
const { disconnectDb } = await import("@/lib/backend/api");
expect(disconnectDb).toHaveBeenCalledWith("preview-1");
expect(queryStore.tabs.some((tab) => tab.connectionId === "preview-1")).toBe(false);
});
it("removeConnection keeps the tabs of a saved connection for the disconnect handling mode", async () => {
installApiMocks();
const { useConnectionStore } = await import("@/stores/connectionStore");
@@ -1,7 +1,7 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { treeNodePinKey } from "@/lib/app/pinnedItems";
import type { TreeNode } from "@/types/database";
import type { SidebarLayout, TreeNode } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
@@ -24,6 +24,16 @@ function tableNode(name = "users"): TreeNode {
};
}
function connectionGroupNode(id: string, children: TreeNode[] = []): TreeNode {
return {
id,
label: id,
type: "connection-group",
isExpanded: true,
children,
};
}
describe("connectionStore pinned tree node removal", () => {
beforeEach(() => {
vi.resetModules();
@@ -58,6 +68,99 @@ describe("connectionStore pinned tree node removal", () => {
expect(store.isTreeNodePinned(replacement)).toBe(false);
});
it("removes pins and selection state for a deleted group and all of its nested groups", async () => {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ saveSidebarLayout: vi.fn().mockResolvedValue(undefined) }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const child = connectionGroupNode("group-child");
const parent = connectionGroupNode("group-parent", [child]);
const layout: SidebarLayout = {
groups: [
{ id: parent.id, name: "Parent", collapsed: false },
{ id: child.id, name: "Child", collapsed: false },
],
order: [{ type: "group", id: parent.id, children: [{ type: "group", id: child.id, children: [] }] }],
};
store.sidebarLayout = layout;
store.treeNodes = [parent];
store.toggleTreeNodePin(parent);
store.toggleTreeNodePin(child);
store.selectedTreeNodeIds = [parent.id, child.id];
store.selectedTreeNodeId = child.id;
store.treeSelectionAnchorId = parent.id;
store.connectionMultiSelectActive = true;
await store.deleteConnectionGroup(parent.id);
expect(store.sidebarLayout.groups).toEqual([]);
expect(store.isTreeNodePinned(parent)).toBe(false);
expect(store.isTreeNodePinned(child)).toBe(false);
expect(store.selectedTreeNodeIds).toEqual([]);
expect(store.selectedTreeNodeId).toBeNull();
expect(store.treeSelectionAnchorId).toBeNull();
expect(store.connectionMultiSelectActive).toBe(false);
});
it("keeps groups in memory when layout persistence fails", async () => {
const saveSidebarLayout = vi.fn().mockRejectedValue(new Error("layout unavailable"));
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ saveSidebarLayout }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const group = connectionGroupNode("group-1");
const layout: SidebarLayout = {
groups: [{ id: group.id, name: group.label, collapsed: false }],
order: [{ type: "group", id: group.id, children: [] }],
};
store.sidebarLayout = layout;
store.treeNodes = [group];
await expect(store.deleteConnectionGroup(group.id)).rejects.toThrow("layout unavailable");
expect(store.sidebarLayout).toStrictEqual(layout);
expect(store.treeNodes).toEqual([group]);
expect(saveSidebarLayout).toHaveBeenCalledWith({ groups: [], order: [] });
expect(saveSidebarLayout).toHaveBeenLastCalledWith(layout);
});
it("restores saved connections when timeout settings cannot be updated", async () => {
const saveConnections = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
loadEditorSettings: vi.fn().mockResolvedValue(null),
saveConnections,
saveEditorSettings: vi.fn().mockRejectedValue(new Error("settings unavailable")),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useSettingsStore } = await import("@/stores/settingsStore");
const settingsStore = useSettingsStore();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = {
id: "connection-1",
name: "Connection",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "postgres",
password: "",
connect_timeout_inherit: true,
} as const;
store.connections = [connection];
settingsStore.editorSettings.connectTimeoutInheritConnectionIds = [connection.id];
await expect(store.removeConnection(connection.id)).rejects.toThrow("settings unavailable");
expect(store.connections).toEqual([connection]);
expect(settingsStore.editorSettings.connectTimeoutInheritConnectionIds).toEqual([connection.id]);
expect(saveConnections).toHaveBeenCalledWith([]);
expect(saveConnections).toHaveBeenLastCalledWith([connection]);
});
it("recounts parent objectCount from remaining children when a child is removed", async () => {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
+129 -14
View File
@@ -48,7 +48,8 @@ import {
findConnectionLocation,
createGroup as createGroupOp,
renameGroup as renameGroupOp,
deleteGroup as deleteGroupOp,
deleteGroups as deleteGroupsOp,
connectionIdsInGroups as connectionIdsInGroupsOp,
toggleGroupCollapsed as toggleGroupCollapsedOp,
collapseAllGroups as collapseAllGroupsOp,
moveConnectionToGroup as moveConnectionToGroupOp,
@@ -354,6 +355,9 @@ export const useConnectionStore = defineStore("connection", () => {
// computed during scrolling and selection changes.
const selectedTreeNodeIdsSet = computed(() => new Set(selectedTreeNodeIds.value));
const treeSelectionAnchorId = ref<string | null>(null);
// Legacy name: this flag now covers homogeneous checkbox selections for both
// connections and connection groups. Connection-only toolbars still filter
// selected ids against the saved connection list.
const connectionMultiSelectActive = ref(false);
const treeClipboard = ref<TreeClipboard | null>(null);
@@ -763,6 +767,15 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function cleanupRemovedOneTimeConnections(connectionIds: string[]) {
try {
await closeOneTimeConnectionTabs(connectionIds);
} catch (error) {
console.warn("[DBX][connection:delete:one-time-tab-cleanup-failed]", { connectionIds, error });
}
releaseOneTimeRuntimeConnections(connectionIds);
}
function cancelDisconnectKey(connectionId: string, attempt: number): string {
return `${connectionId}:${attempt}`;
}
@@ -2778,13 +2791,14 @@ export const useConnectionStore = defineStore("connection", () => {
const removedIds = new Set(connectionIds);
const oneTimeIds = connectionIds.filter((id) => getConfig(id)?.one_time === true);
const nextConnections = connections.value.filter((c) => !removedIds.has(c.id));
await persistConnections(nextConnections);
await closeOneTimeConnectionTabs(oneTimeIds);
releaseOneTimeRuntimeConnections(oneTimeIds);
await persistTimeoutInheritanceIds(
settingsStore.editorSettings.connectTimeoutInheritConnectionIds.filter((id) => !removedIds.has(id)),
settingsStore.editorSettings.queryTimeoutInheritConnectionIds.filter((id) => !removedIds.has(id)),
);
let nextLayout = sidebarLayout.value;
for (const id of removedIds) nextLayout = removeConnectionFromSidebarLayout(nextLayout, id);
await persistConnectionDeletion(nextConnections, nextLayout);
applyConnectionRemoval(removedIds, nextConnections, nextLayout);
await cleanupRemovedOneTimeConnections(oneTimeIds);
}
function applyConnectionRemoval(removedIds: ReadonlySet<string>, nextConnections: ConnectionConfig[], nextLayout: SidebarLayout) {
connections.value = nextConnections;
syncTimeoutInheritanceBackup();
let nextPinnedOrder = pinnedTreeNodeOrder.value;
@@ -2802,10 +2816,9 @@ export const useConnectionStore = defineStore("connection", () => {
clearPrimaryVisibleObjectNames(id);
clearConnectionIdentifierQuote(id);
clearConnectionHealthCheck(id);
sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id);
}
sidebarLayout.value = nextLayout;
rebuildTreeNodes();
persistSidebarLayoutDebounced();
if (activeConnectionId.value && removedIds.has(activeConnectionId.value)) {
activeConnectionId.value = null;
}
@@ -7308,11 +7321,10 @@ export const useConnectionStore = defineStore("connection", () => {
async function persistTimeoutInheritanceIds(connectIds: string[], queryIds: string[]) {
if (sameIds(connectIds, settingsStore.editorSettings.connectTimeoutInheritConnectionIds) && sameIds(queryIds, settingsStore.editorSettings.queryTimeoutInheritConnectionIds)) return;
settingsStore.updateEditorSettings({
await settingsStore.updateEditorSettingsAndPersist({
connectTimeoutInheritConnectionIds: connectIds,
queryTimeoutInheritConnectionIds: queryIds,
});
await settingsStore.persistEditorSettings();
}
async function persistTimeoutInheritance(connectionId: string, connectInherit: boolean, queryInherit: boolean) {
@@ -7341,6 +7353,66 @@ export const useConnectionStore = defineStore("connection", () => {
});
}
async function persistConnectionDeletion(nextConnections: ConnectionConfig[], nextLayout: SidebarLayout) {
const previousConnections = connections.value;
const previousLayout = sidebarLayout.value;
const previousConnectTimeoutIds = [...settingsStore.editorSettings.connectTimeoutInheritConnectionIds];
const previousQueryTimeoutIds = [...settingsStore.editorSettings.queryTimeoutInheritConnectionIds];
const nextConnectTimeoutIds = previousConnectTimeoutIds.filter((id) => nextConnections.some((connection) => connection.id === id));
const nextQueryTimeoutIds = previousQueryTimeoutIds.filter((id) => nextConnections.some((connection) => connection.id === id));
const connectionsChanged = nextConnections.length !== previousConnections.length || nextConnections.some((connection, index) => connection !== previousConnections[index]);
const timeoutSettingsChanged = !sameIds(nextConnectTimeoutIds, previousConnectTimeoutIds) || !sameIds(nextQueryTimeoutIds, previousQueryTimeoutIds);
const layoutChanged = nextLayout !== previousLayout;
let connectionsPersisted = false;
let timeoutSettingsPersisted = false;
let layoutSaveStarted = false;
try {
if (connectionsChanged) {
await persistConnections(nextConnections);
connectionsPersisted = true;
}
if (timeoutSettingsChanged) {
await persistTimeoutInheritanceIds(nextConnectTimeoutIds, nextQueryTimeoutIds);
timeoutSettingsPersisted = true;
}
if (layoutChanged) {
layoutSaveStarted = true;
await api.saveSidebarLayout(nextLayout);
}
} catch (error) {
const rollbackErrors: unknown[] = [];
if (layoutSaveStarted) {
try {
await api.saveSidebarLayout(previousLayout);
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (timeoutSettingsPersisted) {
try {
await persistTimeoutInheritanceIds(previousConnectTimeoutIds, previousQueryTimeoutIds);
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (connectionsPersisted) {
try {
await persistConnections(previousConnections);
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (rollbackErrors.length) {
const details = rollbackErrors.map((rollbackError) => (rollbackError instanceof Error ? rollbackError.message : String(rollbackError))).join("; ");
const recoveryError = new Error(`Connection deletion failed and recovery was incomplete: ${details}`);
(recoveryError as Error & { cause?: unknown }).cause = error;
throw recoveryError;
}
throw error;
}
}
async function applyGlobalTimeouts({ connectTimeoutSecs, queryTimeoutSecs }: { connectTimeoutSecs?: number; queryTimeoutSecs?: number }) {
const nextConnections = connections.value.map((connection) => {
const nextConnectTimeout = connectTimeoutSecs !== undefined && connection.connect_timeout_inherit === true ? connectTimeoutSecs : connection.connect_timeout_secs;
@@ -7447,6 +7519,43 @@ export const useConnectionStore = defineStore("connection", () => {
persistSidebarLayoutDebounced();
}
async function removeConnectionGroups(groupIds: Iterable<string>, deleteConnections = false): Promise<string[]> {
const uniqueGroupIds = [...new Set(groupIds)];
const previousLayout = sidebarLayout.value;
const connectionIds = deleteConnections ? connectionIdsInGroupsOp(sidebarLayout.value, uniqueGroupIds).filter((id) => connections.value.some((connection) => connection.id === id)) : [];
const oneTimeIds = connectionIds.filter((id) => getConfig(id)?.one_time === true);
const removedConnectionIds = new Set(connectionIds);
const nextConnections = removedConnectionIds.size ? connections.value.filter((connection) => !removedConnectionIds.has(connection.id)) : connections.value;
let layoutAfterConnectionRemoval = previousLayout;
for (const id of removedConnectionIds) layoutAfterConnectionRemoval = removeConnectionFromSidebarLayout(layoutAfterConnectionRemoval, id);
const nextLayout = deleteGroupsOp(layoutAfterConnectionRemoval, uniqueGroupIds);
if (nextLayout === previousLayout && nextConnections === connections.value) return [];
await persistConnectionDeletion(nextConnections, nextLayout);
if (removedConnectionIds.size) {
applyConnectionRemoval(removedConnectionIds, nextConnections, nextLayout);
} else {
sidebarLayout.value = nextLayout;
rebuildTreeNodes();
}
await cleanupRemovedOneTimeConnections(oneTimeIds);
const remainingGroupIds = new Set(nextLayout.groups.map((group) => group.id));
const removedGroupIds = new Set(previousLayout.groups.filter((group) => !remainingGroupIds.has(group.id)).map((group) => group.id));
if (removedGroupIds.size) {
const nextPinnedOrder = pinnedTreeNodeOrder.value.filter((pinId) => !removedGroupIds.has(pinId));
if (nextPinnedOrder.length !== pinnedTreeNodeOrder.value.length) {
setPinnedTreeNodeOrder(nextPinnedOrder);
persistPinnedTreeNodeIds();
}
selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => !removedGroupIds.has(id));
if (selectedTreeNodeId.value && removedGroupIds.has(selectedTreeNodeId.value)) selectedTreeNodeId.value = null;
if (treeSelectionAnchorId.value && removedGroupIds.has(treeSelectionAnchorId.value)) treeSelectionAnchorId.value = null;
if (!selectedTreeNodeIds.value.length) connectionMultiSelectActive.value = false;
}
return connectionIds;
}
function collapseAllTreeNodes() {
updateLayoutAndRebuild(collapseAllGroupsOp(sidebarLayout.value));
collapseExpandedTreeNodes(treeNodes.value);
@@ -8139,8 +8248,14 @@ export const useConnectionStore = defineStore("connection", () => {
renameConnectionGroup(groupId: string, name: string) {
updateLayoutAndRebuild(renameGroupOp(sidebarLayout.value, groupId, name));
},
deleteConnectionGroup(groupId: string) {
updateLayoutAndRebuild(deleteGroupOp(sidebarLayout.value, groupId));
async deleteConnectionGroup(groupId: string) {
await removeConnectionGroups([groupId]);
},
async deleteConnectionGroups(groupIds: Iterable<string>, deleteConnections = false) {
return await removeConnectionGroups(groupIds, deleteConnections);
},
connectionIdsInGroups(groupIds: Iterable<string>) {
return connectionIdsInGroupsOp(sidebarLayout.value, groupIds);
},
toggleConnectionGroupCollapsed(groupId: string) {
updateLayoutAndRebuild(toggleGroupCollapsedOp(sidebarLayout.value, groupId));