feat(sidebar): support bulk connection group deletion
This commit is contained in:
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">
|
||||
|
||||
+59
@@ -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();
|
||||
|
||||
+35
-1
@@ -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;
|
||||
}
|
||||
Vendored
+88
-4
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "すべての接続を選択",
|
||||
|
||||
@@ -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: "데이터베이스 검색",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "在数据库中查找",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user