feat(sidebar): support hierarchical batch connection moves

This commit is contained in:
二丫讲梵
2026-08-22 19:18:11 +08:00
committed by GitHub
parent 36c9500a4e
commit 904efff6b3
10 changed files with 105 additions and 38 deletions
@@ -135,7 +135,10 @@ const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
function createStore() {
return reactive({
connections: [{ id: "conn-visible" }, { id: "conn-hidden" }, { id: "conn-next" }],
sidebarLayout: { groups: [{ id: "group-a", name: "Group A" }] },
sidebarLayout: {
groups: [{ id: "group-a", name: "Group A" }],
order: [{ type: "group", id: "group-a", children: [] }],
},
selectedTreeNodeIds: [] as string[],
selectedTreeNodeId: null as string | null,
treeSelectionAnchorId: null as string | null,
@@ -2,7 +2,7 @@
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import { Upload, Download, FolderPlus, RefreshCw, ChevronsLeft, ChevronsUp, Trash2, FolderInput, Check, Minus, Square, X } from "@lucide/vue";
import { Upload, Download, FolderPlus, FolderOpen, RefreshCw, ChevronsLeft, ChevronsUp, Trash2, FolderInput, Check, Minus, Square, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@@ -10,6 +10,7 @@ import LightDropdown from "@/components/ui/LightDropdown.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import { applyConnectionMultiSelection, emptyConnectionMultiSelection, isExitConnectionMultiSelectionShortcut } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import type { QueryTab, TreeNode } from "@/types/database";
@@ -53,9 +54,12 @@ const allConnectionsSelected = computed(() => allConnectionIds.value.length > 0
const selectAllIcon = computed(() => (allConnectionsSelected.value ? Check : selectedConnectionCount.value > 0 ? Minus : Square));
const selectAllLabel = computed(() => (allConnectionsSelected.value ? t("connectionGroup.deselectAllConnections") : t("connectionGroup.selectAllConnections")));
const moveGroupItems = computed(() => [
...connectionStore.sidebarLayout.groups.map((group) => ({
...connectionGroupDestinationRows(connectionStore.sidebarLayout).map((group) => ({
value: group.id,
label: group.name,
title: group.path.join(" / "),
icon: FolderOpen,
indentLevel: group.depth,
})),
{
value: UNGROUPED_GROUP_VALUE,
@@ -74,6 +74,7 @@ import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { connectionUsesVisibleSchemaFilter } from "@/lib/database/visibleDatabases";
import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout";
import { sidebarConnectionVisibleFilterMenu } from "@/lib/sidebar/sidebarVisibleFilterMenu";
import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout";
import { objectTypesForGroupNode } from "@/lib/table/tableTree";
import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting";
import { isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers";
@@ -148,7 +149,7 @@ import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connec
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tableDataCopyColumnOptions, type TableClipboardContext, type TableClipboardTableContext } from "@/lib/table/tableClipboard";
import { selectedSidebarBatchTargets, selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes } from "@/lib/sidebar/sidebarTreeSelection";
import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionEditTarget, selectedConnectionMoveTargets } from "@/lib/sidebar/sidebarConnectionSelection";
import { connectionSupportsDatabaseUserAdmin, resolveDatabaseUserAdminProviderForConnection, type DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin";
import { authorizationPlanSql, authorizationPlanStatus, buildCreateDatabaseAuthorizationPlan, executeAuthorizationPlan, type AuthorizationPlan, type AuthorizationStepResult } from "@/lib/database/databaseAuthorizationPlan";
import { connectionSupportsProcessList } from "@/lib/database/processListDrivers";
@@ -4539,23 +4540,7 @@ function getTreeItemDialogController(): Record<string, any> {
return treeItemDialogController;
}
const availableGroups = computed(() => connectionStore.sidebarLayout.groups);
const currentGroupId = computed(() => {
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return null;
const find = (entries: typeof connectionStore.sidebarLayout.order): string | null => {
for (const entry of entries) {
if (entry.type !== "group") continue;
if ((entry.children ?? entry.connectionIds?.map((id) => ({ type: "connection" as const, id })) ?? []).some((child) => child.type === "connection" && child.id === activeNode.value.connectionId)) {
return entry.id;
}
const found = find(entry.children ?? []);
if (found) return found;
}
return null;
};
return find(connectionStore.sidebarLayout.order);
});
const availableGroups = computed(() => connectionGroupDestinationRows(connectionStore.sidebarLayout));
onBeforeUnmount(() => {
stopDangerDialogRouting?.();
@@ -4766,14 +4751,18 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
});
}
items.push({ label: "", separator: true });
if (availableGroups.value.length > 0 || currentGroupId.value) {
const groupChildren: ContextMenuItem[] = availableGroups.value.map((group: { id: string; name: string }) => ({
const moveTargets = selectedConnectionMoveTargets(node, selectedTreeNodesInVisibleOrder());
const allMoveTargetsInGroup = (groupId: string | null) => moveTargets.length > 0 && moveTargets.every((target) => connectionStore.groupIdForConnection(target.connectionId) === groupId);
if (availableGroups.value.length > 0 || !allMoveTargetsInGroup(null)) {
const groupChildren: ContextMenuItem[] = availableGroups.value.map((group) => ({
label: group.name,
title: group.path.join(" / "),
action: () => moveToGroup(group.id),
icon: FolderOpen,
disabled: group.id === currentGroupId.value,
indentLevel: group.depth,
disabled: allMoveTargetsInGroup(group.id),
}));
if (currentGroupId.value) {
if (!allMoveTargetsInGroup(null)) {
groupChildren.push({ label: "", separator: true });
groupChildren.push({ label: t("connectionGroup.ungrouped"), action: () => moveToGroup(null) });
}
@@ -282,7 +282,7 @@ onBeforeUnmount(() => {
<div v-if="item.separator" class="-mx-1 my-1 flex items-center px-1">
<div class="h-px flex-1 bg-border/70" />
</div>
<button v-else :disabled="itemIsDisabled(item)" :class="[...itemButtonClass(item.variant), activeSubmenuTriggerClass(item, index)]" @click="handleItemClick(item)" @mouseenter="(e) => onItemMouseEnter(index, e)" @mouseleave="onItemMouseLeave">
<button v-else :disabled="itemIsDisabled(item)" :title="item.title" :class="[...itemButtonClass(item.variant), activeSubmenuTriggerClass(item, index)]" @click="handleItemClick(item)" @mouseenter="(e) => onItemMouseEnter(index, e)" @mouseleave="onItemMouseLeave">
<span class="flex size-4 shrink-0 items-center justify-center">
<Check v-if="item.checked" class="size-4 text-primary" />
<component :is="item.icon" v-else-if="item.icon" :class="['size-4', item.iconClass]" />
@@ -313,7 +313,7 @@ onBeforeUnmount(() => {
<div v-if="child.separator" class="-mx-1 my-1 flex items-center px-1">
<div class="h-px flex-1 bg-border/70" />
</div>
<button v-else :disabled="itemIsDisabled(child)" :class="itemButtonClass(child.variant)" @click="handleSubItemClick(child)">
<button v-else :disabled="itemIsDisabled(child)" :title="child.title" :class="itemButtonClass(child.variant)" :style="{ paddingInlineStart: `${0.5 + (child.indentLevel ?? 0) * 0.75}rem` }" @click="handleSubItemClick(child)">
<span class="flex size-4 shrink-0 items-center justify-center">
<Check v-if="child.checked" class="size-4 text-primary" />
<component :is="child.icon" v-else-if="child.icon" :class="['size-4', child.iconClass]" />
@@ -11,6 +11,7 @@ export interface LightDropdownItem {
leadingText?: string;
disabled?: boolean;
separatorBefore?: boolean;
indentLevel?: number;
}
const props = withDefaults(
@@ -83,13 +84,20 @@ const menuStyle = computed(() => ({
left: `${x.value}px`,
top: `${y.value}px`,
minWidth: props.matchTriggerWidth ? `${minWidth.value}px` : undefined,
maxHeight: "min(420px, calc(100vh - 16px))",
}));
function onScroll(event: Event) {
const target = event.target;
if (target instanceof Node && menuRef.value?.contains(target)) return;
close();
}
function close() {
open.value = false;
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
document.removeEventListener("keydown", onKeydown);
window.removeEventListener("scroll", close, true);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", close);
}
@@ -123,7 +131,7 @@ function openMenu() {
nextTick(fitPositionToViewport);
document.addEventListener("pointerdown", onOutsidePointerDown, true);
document.addEventListener("keydown", onKeydown);
window.addEventListener("scroll", close, true);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", close);
}
@@ -169,7 +177,7 @@ onBeforeUnmount(close);
<ChevronDown v-if="showChevron" class="h-3 w-3 opacity-50" />
</button>
<Teleport to="body">
<div v-if="open" ref="menuRef" class="fixed z-50 min-w-32 rounded-md p-1 cn-menu-translucent text-popover-foreground" :class="contentClass" :style="menuStyle" role="menu">
<div v-if="open" ref="menuRef" class="fixed z-50 min-w-32 overflow-x-hidden overflow-y-auto rounded-md p-1 cn-menu-translucent text-popover-foreground" :class="contentClass" :style="menuStyle" role="menu">
<div v-if="label" :class="labelClass">{{ label }}</div>
<div v-if="label" class="bg-border -mx-1 my-1 h-px" />
<template v-for="item in items" :key="item.value">
@@ -180,6 +188,7 @@ onBeforeUnmount(close);
:class="[itemClass, highlightSelected && isItemSelected(item) ? selectedItemClass : '']"
:disabled="item.disabled"
:title="item.title"
:style="{ paddingInlineStart: `${0.375 + (item.indentLevel ?? 0) * 0.75}rem` }"
role="menuitem"
@click="selectItem(item)"
>
@@ -7,6 +7,8 @@ export interface ContextMenuItem {
separator?: boolean;
icon?: Component;
iconClass?: string;
title?: string;
indentLevel?: number;
checked?: boolean;
// Raw shortcut syntax such as `Mod+C` or `Shift+Alt+U`; display formatting stays in this component.
shortcut?: string;
@@ -14,7 +14,7 @@ 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, selectedConnectionGroupDeleteTargets } from "@/lib/sidebar/sidebarConnectionSelection";
import { selectedConnectionDeleteTargets, selectedConnectionDisconnectTargets, selectedConnectionDuplicateTargets, selectedConnectionGroupDeleteTargets, selectedConnectionMoveTargets } from "@/lib/sidebar/sidebarConnectionSelection";
import { releaseConnectionFromMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { connectionDeleteTargetSnapshot, connectionGroupDeleteTargetSnapshot, deleteConnectionsWithGroup, showDeleteConfirm, showDeleteGroupConfirm, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
@@ -386,19 +386,20 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
}
function moveToGroup(groupId: string | null) {
const connectionId = activeNode.value.connectionId;
if (!connectionId) return;
connectionStore.moveConnectionToGroup(connectionId, groupId);
releaseConnectionFromMultiSelection(connectionStore, connectionId);
const targets = selectedConnectionMoveTargets(activeNode.value, selectedTreeNodesInVisibleOrder());
if (!targets.length) return;
for (const target of targets) connectionStore.moveConnectionToGroup(target.connectionId, groupId);
for (const target of targets) releaseConnectionFromMultiSelection(connectionStore, target.connectionId);
}
function createGroupAndMoveConnection(name: string): boolean {
const node = sidebarFormTarget.value ?? activeNode.value;
const normalizedName = name.trim();
if (!normalizedName || !node.connectionId) return false;
const targets = selectedConnectionMoveTargets(node, selectedTreeNodesInVisibleOrder());
if (!normalizedName || !targets.length) return false;
const groupId = connectionStore.createConnectionGroup(normalizedName);
connectionStore.moveConnectionToGroup(node.connectionId, groupId);
releaseConnectionFromMultiSelection(connectionStore, node.connectionId);
for (const target of targets) connectionStore.moveConnectionToGroup(target.connectionId, groupId);
for (const target of targets) releaseConnectionFromMultiSelection(connectionStore, target.connectionId);
return true;
}
@@ -42,6 +42,10 @@ export function selectedConnectionDisconnectTargets(currentNode: TreeNode, selec
return selectedConnectionActionTargets(currentNode, selectedNodes);
}
export function selectedConnectionMoveTargets(currentNode: TreeNode, selectedNodes: TreeNode[]): ConnectionTreeNode[] {
return selectedConnectionActionTargets(currentNode, selectedNodes);
}
export function selectedConnectionClipboardTargets(currentNode: TreeNode, selectedNodes: TreeNode[]): ConnectionTreeNode[] {
return selectedConnectionActionTargets(currentNode, selectedNodes);
}
@@ -337,6 +337,36 @@ export function buildConnectionGroupPathMap(layout: SidebarLayout): Map<string,
return paths;
}
export interface ConnectionGroupDestinationRow {
id: string;
name: string;
depth: number;
path: string[];
}
/** Build selectable connection-group destinations in the same tree order as the sidebar. */
export function connectionGroupDestinationRows(layout: SidebarLayout): ConnectionGroupDestinationRow[] {
const groupMap = new Map(layout.groups.map((group) => [group.id, group]));
const rows: ConnectionGroupDestinationRow[] = [];
const seen = new Set<string>();
const visit = (entries: SidebarOrderEntry[], parentPath: string[]) => {
for (const entry of entries) {
if (entry.type !== "group" || seen.has(entry.id)) continue;
const group = groupMap.get(entry.id);
if (!group) continue;
seen.add(entry.id);
const path = [...parentPath, group.name];
rows.push({ id: group.id, name: group.name, depth: parentPath.length, path });
visit(entryChildren(entry), path);
}
};
visit(layout.order, []);
return rows;
}
function findGroupEntry(entries: SidebarOrderEntry[], groupId: string): Extract<SidebarOrderEntry, { type: "group" }> | null {
for (const entry of entries) {
if (entry.type !== "group") continue;
+25
View File
@@ -16,6 +16,7 @@ import {
filterSidebarLayoutByConnectionIds,
collapseAllGroups,
buildConnectionGroupPathMap,
connectionGroupDestinationRows,
} from "../../apps/desktop/src/lib/sidebar/sidebarLayout.ts";
import type { ConnectionConfig, SidebarLayout } from "../../apps/desktop/src/types/database.ts";
@@ -61,6 +62,30 @@ test("builds all connection group paths in one layout traversal", () => {
assert.equal(paths.has("missing"), false);
});
test("builds connection group destinations in sidebar hierarchy order", () => {
const rows = connectionGroupDestinationRows({
groups: [
{ id: "project", name: "Project", collapsed: false },
{ id: "staging", name: "Staging", collapsed: false },
{ id: "archive", name: "Archive", collapsed: false },
],
order: [
{
type: "group",
id: "project",
children: [{ type: "group", id: "staging", children: [] }],
},
{ type: "group", id: "archive", children: [] },
],
});
assert.deepEqual(rows, [
{ id: "project", name: "Project", depth: 0, path: ["Project"] },
{ id: "staging", name: "Staging", depth: 1, path: ["Project", "Staging"] },
{ id: "archive", name: "Archive", depth: 0, path: ["Archive"] },
]);
});
// --- reconcileLayout ---
test("reconcileLayout returns all connections ungrouped when layout is null", () => {