mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(sidebar): support hierarchical batch connection moves
This commit is contained in:
+4
-1
@@ -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
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user