fix(sidebar): improve connection multi-select actions

This commit is contained in:
zipg authored and GitHub committed 2026-08-12 20:17:57 +08:00
1 parent 668c5ddadb
commit 105920047a
24 files changed
+510 -44

No files matched your search

@@ -212,6 +212,44 @@ describe("AppSidebar connection multi-select moves", () => {
expect(mocks.store.selectedTreeNodeIds).toEqual([]);
});
it("shows the selected count beside the connection title instead of as a toolbar item", async () => {
setSelection(["conn-visible", "conn-hidden"]);
const host = await mountSidebar();
const count = host.querySelector("[data-connection-selection-count]");
expect(count?.textContent?.trim()).toBe("connectionGroup.selectedConnections");
expect(count?.previousElementSibling?.textContent?.trim()).toBe("sidebar.connections");
});
it("exits connection multi-select when Escape is pressed in the sidebar", async () => {
setSelection(["conn-visible", "conn-hidden"]);
const host = await mountSidebar();
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
host.querySelector<HTMLElement>(".app-sidebar-panel")!.dispatchEvent(event);
await nextTick();
expect(event.defaultPrevented).toBe(true);
expect(mocks.store.selectedTreeNodeIds).toEqual([]);
expect(mocks.store.selectedTreeNodeId).toBeNull();
expect(mocks.store.treeSelectionAnchorId).toBeNull();
expect(mocks.store.connectionMultiSelectActive).toBe(false);
});
it("does not exit connection multi-select when Escape comes from an input", async () => {
setSelection(["conn-visible", "conn-hidden"]);
const host = await mountSidebar();
click(host.querySelector('[data-tooltip="connectionGroup.createGroup"] button'));
await nextTick();
const input = host.querySelector<HTMLInputElement>("[data-dialog] input");
input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
await nextTick();
expect(mocks.store.selectedTreeNodeIds).toEqual(["conn-visible", "conn-hidden"]);
expect(mocks.store.connectionMultiSelectActive).toBe(true);
});
it("keeps selection on cancel and releases it after creating a new group", async () => {
setSelection(["conn-visible", "conn-hidden"]);
const host = await mountSidebar();
@@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import { applyConnectionMultiSelection, emptyConnectionMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { applyConnectionMultiSelection, emptyConnectionMultiSelection, isExitConnectionMultiSelectionShortcut } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
@@ -90,6 +90,18 @@ function clearConnectionMultiSelection() {
applyConnectionMultiSelection(connectionStore, emptyConnectionMultiSelection());
}
function isEditableSidebarTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target.isContentEditable || !!target.closest("[contenteditable='true'], [role='textbox']");
}
function onSidebarKeydown(event: KeyboardEvent) {
if (event.defaultPrevented || !connectionStore.connectionMultiSelectActive || !isExitConnectionMultiSelectionShortcut(event) || isEditableSidebarTarget(event.target)) return;
event.preventDefault();
event.stopPropagation();
clearConnectionMultiSelection();
}
function toggleAllConnectionsSelected() {
if (allConnectionsSelected.value) {
clearConnectionMultiSelection();
@@ -153,13 +165,17 @@ defineExpose({ focusSearch });
</script>
<template>
<div class="app-sidebar-panel h-full shrink-0 relative select-none" :class="classicLayout ? '' : 'rounded-md border border-border/80 bg-background'" :style="{ width: sidebarWidth + 'px' }">
<div class="app-sidebar-panel h-full shrink-0 relative select-none" :class="classicLayout ? '' : 'rounded-md border border-border/80 bg-background'" :style="{ width: sidebarWidth + 'px' }" @keydown="onSidebarKeydown">
<div class="h-full flex flex-col overflow-hidden">
<div class="app-sidebar-toolbar flex items-center gap-px px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20" :class="classicLayout ? 'h-9' : 'h-10'">
<span class="flex self-stretch items-center truncate" data-tauri-drag-region>{{ t("sidebar.connections") }}</span>
<span class="flex min-w-0 self-stretch items-center" data-tauri-drag-region>
<span class="truncate" data-tauri-drag-region>{{ t("sidebar.connections") }}</span>
<span v-if="showConnectionMultiSelectToolbar" class="ml-1.5 shrink-0 text-[11px] font-normal text-muted-foreground/80" data-connection-selection-count>
{{ t("connectionGroup.selectedConnections", { count: selectedConnectionCount }) }}
</span>
</span>
<span class="flex-1 self-stretch" data-tauri-drag-region />
<template v-if="showConnectionMultiSelectToolbar">
<span class="mr-1 text-[11px] font-medium text-muted-foreground">{{ selectedConnectionCount }}</span>
<LightTooltip :text="t('connectionGroup.createGroup')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="openCreateSelectedGroupDialog">
<FolderPlus class="h-3 w-3" />
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, shallowRef, computed, nextTick, watch, provide, onMounted, onUnmounted, type Component, type ComponentPublicInstance, type CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, CircleDot, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw } from "@lucide/vue";
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, CircleDot, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw, Loader2, Unplug } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -52,6 +52,7 @@ import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNaturalContentWidth, sidebarTreeNodeComment, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { formatSidebarObjectStorage, sidebarTableStorageScopes, supportsSidebarTableStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
import { sidebarScrollbarGeometry as calculateSidebarScrollbarGeometry } from "@/lib/sidebar/sidebarScrollbar";
import { disconnectSidebarConnections } from "@/lib/sidebar/sidebarConnectionDisconnect";
const { t } = useI18n();
const store = useConnectionStore();
@@ -61,6 +62,7 @@ const { toast } = useToast();
const searchQuery = ref("");
const deferredSearchQuery = ref("");
const showConnectedConnectionsOnly = ref(false);
const isDisconnectingAllActiveConnections = ref(false);
const searchInputRef = ref<HTMLInputElement>();
const rootRef = ref<HTMLElement>();
const pointerInsideTree = ref(false);
@@ -159,6 +161,39 @@ watch(
{ flush: "sync" },
);
watch(
[showConnectedConnectionsOnly, () => store.connectedIds.size],
([showConnectedOnly, activeConnectionCount]) => {
if (showConnectedOnly && activeConnectionCount === 0) showConnectedConnectionsOnly.value = false;
},
{ flush: "sync" },
);
async function disconnectAllActiveConnections() {
if (isDisconnectingAllActiveConnections.value) return;
const connectionIds = [...store.connectedIds];
if (!connectionIds.length) {
showConnectedConnectionsOnly.value = false;
return;
}
isDisconnectingAllActiveConnections.value = true;
try {
const result = await disconnectSidebarConnections(connectionIds, (connectionId) => store.disconnect(connectionId));
if (!result.failed) {
toast(t("connection.disconnectedSelected", { count: connectionIds.length }), 2000);
} else if (result.succeeded > 0) {
toast(t("connection.disconnectSelectedPartial", { succeeded: result.succeeded, failed: result.failed }), 5000);
} else {
const message = result.firstError instanceof Error ? result.firstError.message : String(result.firstError);
toast(t("connection.saveFailed", { message }), 5000);
}
} finally {
isDisconnectingAllActiveConnections.value = false;
if (store.connectedIds.size === 0) showConnectedConnectionsOnly.value = false;
}
}
function refreshActiveSidebarTableSearches() {
if (isTreeSearchFiltering.value) return;
for (const parentNodeId of Object.keys(store.sidebarTableSearchQueries)) {
@@ -1974,6 +2009,13 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
</div>
</div>
</CustomContextMenu>
<div v-if="showConnectedConnectionsOnly && store.connectedIds.size > 0" class="shrink-0 border-t border-border bg-background px-2 py-2">
<Button type="button" variant="outline" size="sm" class="h-7 w-full justify-center gap-1.5 text-xs" :disabled="isDisconnectingAllActiveConnections" @click="disconnectAllActiveConnections">
<Loader2 v-if="isDisconnectingAllActiveConnections" class="h-3.5 w-3.5 animate-spin" />
<Unplug v-else class="h-3.5 w-3.5" />
{{ t("sidebar.disconnectAllActiveConnections") }}
</Button>
</div>
<SidebarDdlViewDialog
v-if="sidebarDdlTarget"
v-model:open="sidebarDdlOpen"
@@ -140,7 +140,7 @@ import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabil
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tableDataCopyColumnOptions, type TableClipboardContext, type TableClipboardTableContext } from "@/lib/table/tableClipboard";
import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes } from "@/lib/sidebar/sidebarTreeSelection";
import { selectedSidebarBatchTargets, selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes } from "@/lib/sidebar/sidebarTreeSelection";
import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionEditTarget } 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";
@@ -368,6 +368,8 @@ const {
canBackupSqliteDatabase,
backupSqliteDatabase,
disconnectConnection,
connectionDisconnectMenuLabel,
canDisconnectConnection,
cancelConnectionAttempt,
closeDatabaseConnection,
isPinned,
@@ -1934,14 +1936,7 @@ async function refreshMutatedTableDataTabsForNodes(nodes: readonly TreeNode[]) {
}
function selectedBatchDropTargets(): TreeNode[] {
const selected = selectedTreeNodesInVisibleOrder();
if (selected.length <= 1 || !selected.some((node) => node.id === activeNode.value.id)) return [];
const first = selected[0];
if (!first?.connectionId || !first.database || !selected.every((node) => node.type === first.type)) return [];
if (!selected.every((node) => node.connectionId === first.connectionId && node.database === first.database && canDropTreeNode(node))) {
return [];
}
return selected;
return selectedSidebarBatchTargets(activeNode.value, selectedTreeNodesInVisibleOrder(), canDropTreeNode);
}
function selectedBatchTableTargets(): TreeNode[] {
@@ -4152,10 +4147,10 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
if (node.type === "connection") {
if (isConnecting.value) {
items.push({ label: t("connection.cancelConnecting"), action: cancelConnectionAttempt, icon: X });
} else if (canDisconnectConnection()) {
items.push({ label: connectionDisconnectMenuLabel(), action: disconnectConnection, icon: Unplug });
} else if (!isConnected.value) {
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Plug });
} else {
items.push({ label: t("contextMenu.closeConnection"), action: disconnectConnection, icon: Unplug });
}
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
@@ -5044,11 +5039,17 @@ function bindMenuTarget(items: ContextMenuItem[], target: SidebarActionTarget, s
function buildContextMenu(node: TreeNode): ContextMenuItem[] {
const previousNode = activeNode.value;
const menuContext = createSidebarMenuContext(node, connectionStore.selectedTreeNodeIds, databaseTypeForNode(node));
acceptedSelectionIds = menuContext.selectedNodeIds;
activateRuntimeNode(node);
claimTreeItemDialogOwnership();
ensureDangerDialogRouting();
routeTreeItemDialogController();
const rawItems = treeItemMenuItems();
let rawItems: ContextMenuItem[];
try {
rawItems = treeItemMenuItems();
} finally {
acceptedSelectionIds = null;
}
// Normalization is intentionally evaluated only when a menu opens. Besides
// parity tests, it provides deterministic action identifiers for diagnostics.
normalizeSidebarMenuDescriptors(menuContext, rawItems);
@@ -59,8 +59,8 @@ import { connectionDisplayUrlScheme } from "@/lib/connection/connectionPresentat
import { hexToRgba } from "@/lib/common/color";
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelTooltip";
import { treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection";
import { applyConnectionMultiSelection, connectionMultiSelectionAfterToggle } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { filterSidebarModifierSelectionIds, supportsSidebarModifierSelection, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection";
import { applyConnectionMultiSelection, applyTreeNodeSelection, connectionMultiSelectionAfterToggle } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
@@ -524,25 +524,45 @@ function selectSingleTreeNode(node: TreeNode) {
}
function toggleTreeNodeSelection(node: TreeNode) {
connectionStore.connectionMultiSelectActive = false;
if (!supportsSidebarModifierSelection(node)) {
selectSingleTreeNode(node);
return;
}
const ids = new Set(connectionStore.selectedTreeNodeIds);
if (ids.has(node.id)) ids.delete(node.id);
else ids.add(node.id);
connectionStore.selectedTreeNodeIds = ids.size ? [...ids] : [node.id];
connectionStore.selectedTreeNodeId = node.id;
connectionStore.treeSelectionAnchorId = node.id;
const filteredIds = filterSidebarModifierSelectionIds(visibleTreeNodes(), [...ids]);
applyTreeNodeSelection(
connectionStore,
{
nodeIds: filteredIds.length ? filteredIds : [node.id],
activeNodeId: node.id,
anchorNodeId: node.id,
},
new Set(connectionStore.connections.map((connection) => connection.id)),
);
}
function selectTreeNodeRange(node: TreeNode) {
connectionStore.connectionMultiSelectActive = false;
if (!supportsSidebarModifierSelection(node)) {
selectSingleTreeNode(node);
return;
}
const visible = visibleTreeNodes();
const anchorId = connectionStore.treeSelectionAnchorId || connectionStore.selectedTreeNodeId || node.id;
const currentIndex = sidebarTreeContext ? sidebarTreeContext.getVisibleNodeIndex(node.id) : -1;
const anchorIndex = sidebarTreeContext ? sidebarTreeContext.getVisibleNodeIndex(anchorId) : -1;
if (sidebarTreeContext && currentIndex >= 0 && anchorIndex >= 0) {
connectionStore.selectedTreeNodeIds = treeSelectionRangeIdsByIndex(visible, currentIndex, anchorIndex, node.id);
connectionStore.selectedTreeNodeId = node.id;
applyTreeNodeSelection(
connectionStore,
{
nodeIds: filterSidebarModifierSelectionIds(visible, treeSelectionRangeIdsByIndex(visible, currentIndex, anchorIndex, node.id)),
activeNodeId: node.id,
anchorNodeId: anchorId,
},
new Set(connectionStore.connections.map((connection) => connection.id)),
);
return;
}
@@ -551,9 +571,16 @@ function selectTreeNodeRange(node: TreeNode) {
return;
}
const rangeIds = treeSelectionRangeIds(visible, node.id, anchorId, connectionStore.selectedTreeNodeId);
connectionStore.selectedTreeNodeIds = rangeIds;
connectionStore.selectedTreeNodeId = node.id;
const rangeIds = filterSidebarModifierSelectionIds(visible, treeSelectionRangeIds(visible, node.id, anchorId, connectionStore.selectedTreeNodeId));
applyTreeNodeSelection(
connectionStore,
{
nodeIds: rangeIds,
activeNodeId: node.id,
anchorNodeId: anchorId,
},
new Set(connectionStore.connections.map((connection) => connection.id)),
);
}
function selectedConnectionIdsForAction(): string[] {
@@ -101,6 +101,7 @@ afterEach(() => {
connectionStore.selectedTreeNodeId = null;
connectionStore.selectedTreeNodeIds = [];
connectionStore.selectedTreeNodeIdsSet = new Set<string>();
connectionStore.treeNodes = [];
connectionStore.treeSelectionAnchorId = null;
settingsStore.editorSettings.sidebarActivation = "double";
vi.restoreAllMocks();
@@ -144,6 +145,37 @@ describe("TreeItem load-more activation", () => {
expect(host.handleRowClick).not.toHaveBeenCalled();
});
it("drops a selected object group when modifier-selecting a real table", async () => {
const group: TreeNode = {
id: "connection-1:app:dbo:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "connection-1",
database: "app",
schema: "dbo",
};
const table: TreeNode = {
id: "connection-1:app:dbo:orders",
label: "orders",
type: "table",
connectionId: "connection-1",
database: "app",
schema: "dbo",
};
connectionStore.treeNodes = [{ ...group, children: [table] }];
connectionStore.selectedTreeNodeId = group.id;
connectionStore.selectedTreeNodeIds = [group.id];
connectionStore.selectedTreeNodeIdsSet = new Set([group.id]);
connectionStore.treeSelectionAnchorId = group.id;
const { row } = await mountTreeItem(table);
row.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1, metaKey: true }));
expect(connectionStore.selectedTreeNodeIds).toEqual([table.id]);
expect(connectionStore.selectedTreeNodeId).toBe(table.id);
expect(connectionStore.treeSelectionAnchorId).toBe(table.id);
});
it("shows the invalid marker for objects reported as invalid", async () => {
const node: TreeNode = {
id: "connection-1:app:dbo:broken_proc",
@@ -8,7 +8,9 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key),
}),
}));
vi.mock("@/composables/useToast", () => ({
@@ -39,16 +41,17 @@ function connectionStore(selectedTreeNodeIds: string[]) {
createConnectionGroup: vi.fn(() => "group-new"),
connectedIds: new Set<string>(),
connectingIds: new Set<string>(),
disconnect: vi.fn().mockResolvedValue(undefined),
isTreeNodeChildrenLoaded: vi.fn(() => false),
getConfig: vi.fn(() => undefined),
};
}
function runtime(activeNode: TreeNode, store: ReturnType<typeof connectionStore>) {
function runtime(activeNode: TreeNode, store: ReturnType<typeof connectionStore>, selectedNodes: TreeNode[] = []) {
return useSidebarConnectionMutationRuntime({
activeNode: shallowRef(activeNode),
releaseActiveNodeReference: vi.fn(),
selectedTreeNodesInVisibleOrder: () => [],
selectedTreeNodesInVisibleOrder: () => selectedNodes,
connectionStore: store as any,
queryStore: { openDatabaseKeys: new Set<string>() } as any,
requestGroupRename: vi.fn(),
@@ -127,3 +130,64 @@ describe("sidebar connection move selection", () => {
expect(store.connectionMultiSelectActive).toBe(true);
});
});
describe("sidebar connection disconnect selection", () => {
beforeEach(() => {
vi.clearAllMocks();
sidebarFormTarget.value = null;
});
it("disconnects every connected connection in the right-clicked selection", async () => {
const nodes = [connectionNode("conn-1"), connectionNode("conn-2"), connectionNode("conn-3")];
const store = connectionStore(nodes.map((node) => node.id));
store.connectedIds = new Set(["conn-1", "conn-3"]);
const { disconnectConnection, connectionDisconnectMenuLabel } = runtime(nodes[1], store, nodes);
expect(connectionDisconnectMenuLabel()).toBe('contextMenu.closeSelectedConnections:{"count":2}');
await disconnectConnection();
expect(store.disconnect.mock.calls).toEqual([["conn-1"], ["conn-3"]]);
expect(mocks.toast).toHaveBeenCalledWith('connection.disconnectedSelected:{"count":2}', 2000);
});
it("recomputes disconnect availability when the selected connections change", () => {
const nodes = [connectionNode("conn-1"), connectionNode("conn-2")];
const selected = [nodes[0]];
const store = connectionStore(selected.map((node) => node.id));
store.connectedIds = new Set(["conn-2"]);
const { canDisconnectConnection, connectionDisconnectMenuLabel } = runtime(nodes[0], store, selected);
expect(canDisconnectConnection()).toBe(false);
selected.push(nodes[1]);
expect(canDisconnectConnection()).toBe(true);
expect(connectionDisconnectMenuLabel()).toBe('contextMenu.closeSelectedConnections:{"count":1}');
});
it("continues disconnecting after one selected connection fails", async () => {
const nodes = [connectionNode("conn-1"), connectionNode("conn-2")];
const store = connectionStore(nodes.map((node) => node.id));
store.connectedIds = new Set(nodes.map((node) => node.connectionId!));
store.disconnect.mockRejectedValueOnce(new Error("failed")).mockResolvedValueOnce(undefined);
const { disconnectConnection } = runtime(nodes[0], store, nodes);
await disconnectConnection();
expect(store.disconnect).toHaveBeenCalledTimes(2);
expect(mocks.toast).toHaveBeenCalledWith('connection.disconnectSelectedPartial:{"succeeded":1,"failed":1}', 5000);
});
it("disconnects only the right-clicked connection outside the current selection", async () => {
const selected = [connectionNode("conn-1"), connectionNode("conn-2")];
const current = connectionNode("conn-3");
const store = connectionStore(selected.map((node) => node.id));
store.connectedIds = new Set(["conn-1", "conn-2", "conn-3"]);
const { disconnectConnection } = runtime(current, store, selected);
await disconnectConnection();
expect(store.disconnect).toHaveBeenCalledWith("conn-3");
expect(store.disconnect).toHaveBeenCalledTimes(1);
});
});
@@ -14,10 +14,11 @@ 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, selectedConnectionDuplicateTargets } from "@/lib/sidebar/sidebarConnectionSelection";
import { selectedConnectionDeleteTargets, selectedConnectionDisconnectTargets, selectedConnectionDuplicateTargets } from "@/lib/sidebar/sidebarConnectionSelection";
import { releaseConnectionFromMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { connectionDeleteTargetSnapshot, showDeleteConfirm, showDeleteGroupConfirm, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
import { disconnectSidebarConnections } from "@/lib/sidebar/sidebarConnectionDisconnect";
interface SidebarConnectionMutationRuntimeOptions {
activeNode: ShallowRef<TreeNode>;
@@ -212,16 +213,37 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
}
async function disconnectConnection() {
const node = activeNode.value;
if (!node.connectionId) return;
try {
await connectionStore.disconnect(node.connectionId);
node.isExpanded = false;
node.children = [];
toast(t("connection.disconnected"), 2000);
} catch (error: any) {
toast(t("connection.saveFailed", { message: error?.message || String(error) }), 5000);
const targets = selectedConnectionDisconnectTargets(activeNode.value, selectedTreeNodesInVisibleOrder()).filter((target) => connectionStore.connectedIds.has(target.connectionId));
if (!targets.length) return;
const result = await disconnectSidebarConnections(
targets.map((target) => target.connectionId),
(connectionId) => connectionStore.disconnect(connectionId),
);
if (!result.failed) {
toast(targets.length > 1 ? t("connection.disconnectedSelected", { count: targets.length }) : t("connection.disconnected"), 2000);
return;
}
if (result.succeeded > 0) {
toast(t("connection.disconnectSelectedPartial", { succeeded: result.succeeded, failed: result.failed }), 5000);
return;
}
const message = result.firstError instanceof Error ? result.firstError.message : String(result.firstError);
toast(t("connection.saveFailed", { message }), 5000);
}
function connectionDisconnectTargets() {
return selectedConnectionDisconnectTargets(activeNode.value, selectedTreeNodesInVisibleOrder());
}
function connectionDisconnectMenuLabel(): string {
const targets = connectionDisconnectTargets();
const connectedCount = targets.filter((target) => connectionStore.connectedIds.has(target.connectionId)).length;
return targets.length > 1 ? t("contextMenu.closeSelectedConnections", { count: connectedCount }) : t("contextMenu.closeConnection");
}
function canDisconnectConnection(): boolean {
return connectionDisconnectTargets().some((target) => connectionStore.connectedIds.has(target.connectionId));
}
async function cancelConnectionAttempt() {
@@ -342,6 +364,8 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
canBackupSqliteDatabase,
backupSqliteDatabase,
disconnectConnection,
connectionDisconnectMenuLabel,
canDisconnectConnection,
cancelConnectionAttempt,
closeDatabaseConnection,
isPinned,
+5
View File
@@ -318,6 +318,7 @@ export default {
showMore: "Show {count} more...",
filterByType: "Filter by type",
showActiveConnectionsOnly: "Show active connections only",
disconnectAllActiveConnections: "Disconnect all active connections",
sortConnections: "Sort connections",
sortConnectionsManual: "Manual order",
sortConnectionsAscending: "Name: A–Z",
@@ -956,6 +957,8 @@ export default {
deletedSelected: "{count} connections deleted",
copied: "Copied",
disconnected: "Disconnected",
disconnectedSelected: "{count} connections disconnected",
disconnectSelectedPartial: "Disconnected {succeeded} connections; {failed} failed",
databaseConnectionClosed: "Closed database connection: {name}",
duplicated: "Connection duplicated",
duplicatedSelected: "{count} connections duplicated",
@@ -2146,6 +2149,7 @@ export default {
deselectConnection: "Deselect connection",
selectAllConnections: "Select all connections",
deselectAllConnections: "Deselect all connections",
selectedConnections: "Selected {count}",
exitMultiSelect: "Exit selection",
renameGroup: "Rename Group",
deleteGroup: "Delete Group",
@@ -2540,6 +2544,7 @@ export default {
contextMenu: {
openConnection: "Open Connection",
closeConnection: "Close Connection",
closeSelectedConnections: "Close Selected {count} Connections",
closeDatabaseConnection: "Close Database Connection",
deleteConnection: "Delete Connection",
deleteSelectedConnections: "Delete Selected {count} Connections",
+5
View File
@@ -319,6 +319,7 @@ export default withEnglishFallback({
showMore: "Mostrar {count} más...",
filterByType: "Filtrar por tipo",
showActiveConnectionsOnly: "Mostrar solo conexiones activas",
disconnectAllActiveConnections: "Desconectar todas las conexiones activas",
sortConnections: "Ordenar conexiones",
sortConnectionsManual: "Orden manual",
sortConnectionsAscending: "Nombre: A–Z",
@@ -755,6 +756,8 @@ export default withEnglishFallback({
deletedSelected: "{count} conexiones eliminadas",
copied: "Copiado",
disconnected: "Desconectado",
disconnectedSelected: "{count} conexiones desconectadas",
disconnectSelectedPartial: "Se desconectaron {succeeded} conexiones; {failed} fallaron",
databaseConnectionClosed: "Conexión de base de datos cerrada: {name}",
duplicated: "Conexión duplicada",
duplicatedSelected: "{count} conexiones duplicadas",
@@ -2090,6 +2093,7 @@ export default withEnglishFallback({
deselectConnection: "Deseleccionar conexión",
selectAllConnections: "Seleccionar todas las conexiones",
deselectAllConnections: "Deseleccionar todas las conexiones",
selectedConnections: "{count} seleccionadas",
exitMultiSelect: "Salir de selección múltiple",
},
databaseSearch: {
@@ -2474,6 +2478,7 @@ export default withEnglishFallback({
contextMenu: {
openConnection: "Abrir conexión",
closeConnection: "Cerrar conexión",
closeSelectedConnections: "Cerrar {count} conexiones seleccionadas",
closeDatabaseConnection: "Cerrar conexión de base de datos",
deleteConnection: "Eliminar conexión",
deleteSelectedConnections: "Eliminar {count} conexiones seleccionadas",
+5
View File
@@ -318,6 +318,7 @@ export default withEnglishFallback({
showMore: "Mostra altri {count}...",
filterByType: "Filtra per tipo",
showActiveConnectionsOnly: "Mostra solo connessioni attive",
disconnectAllActiveConnections: "Disconnetti tutte le connessioni attive",
sortConnections: "Ordina connessioni",
sortConnectionsManual: "Ordine manuale",
sortConnectionsAscending: "Nome: A–Z",
@@ -753,6 +754,8 @@ export default withEnglishFallback({
deletedSelected: "{count} connessioni eliminate",
copied: "Copiato",
disconnected: "Disconnesso",
disconnectedSelected: "{count} connessioni disconnesse",
disconnectSelectedPartial: "Disconnesse {succeeded} connessioni; {failed} non riuscite",
databaseConnectionClosed: "Connessione al database chiusa: {name}",
duplicated: "Connessione duplicata",
duplicatedSelected: "{count} connessioni duplicate",
@@ -2088,6 +2091,7 @@ export default withEnglishFallback({
deselectConnection: "Deseleziona connessione",
selectAllConnections: "Seleziona tutte le connessioni",
deselectAllConnections: "Deseleziona tutte le connessioni",
selectedConnections: "{count} selezionate",
exitMultiSelect: "Esci dalla selezione multipla",
},
databaseSearch: {
@@ -2472,6 +2476,7 @@ export default withEnglishFallback({
contextMenu: {
openConnection: "Apri Connessione",
closeConnection: "Chiudi Connessione",
closeSelectedConnections: "Chiudi {count} connessioni selezionate",
closeDatabaseConnection: "Chiudi Connessione Database",
deleteConnection: "Elimina Connessione",
deleteSelectedConnections: "Elimina {count} connessioni selezionate",
+5
View File
@@ -319,6 +319,7 @@ export default withEnglishFallback({
showMore: "さらに{count}件表示...",
filterByType: "タイプでフィルター",
showActiveConnectionsOnly: "アクティブな接続のみを表示",
disconnectAllActiveConnections: "すべてのアクティブな接続を切断",
sortConnections: "接続を並べ替え",
sortConnectionsManual: "手動順",
sortConnectionsAscending: "名前: A–Z",
@@ -719,6 +720,8 @@ export default withEnglishFallback({
deletedSelected: "{count}件の接続を削除しました",
copied: "コピーしました",
disconnected: "切断しました",
disconnectedSelected: "{count}件の接続を切断しました",
disconnectSelectedPartial: "{succeeded}件の接続を切断し、{failed}件失敗しました",
databaseConnectionClosed: "データベース接続を閉じました: {name}",
duplicated: "接続を複製しました",
duplicatedSelected: "{count}件の接続を複製しました",
@@ -2115,6 +2118,7 @@ export default withEnglishFallback({
deselectConnection: "接続の選択を解除",
selectAllConnections: "すべての接続を選択",
deselectAllConnections: "すべての接続の選択を解除",
selectedConnections: "{count}件選択",
exitMultiSelect: "複数選択を終了",
},
databaseSearch: {
@@ -2499,6 +2503,7 @@ export default withEnglishFallback({
contextMenu: {
openConnection: "接続を開く",
closeConnection: "接続を閉じる",
closeSelectedConnections: "選択した{count}件の接続を閉じる",
closeDatabaseConnection: "データベース接続を閉じる",
deleteConnection: "接続を削除",
deleteSelectedConnections: "選択した{count}件の接続を削除",
+5
View File
@@ -318,6 +318,7 @@ export default withEnglishFallback({
showMore: "{count}개 더 보기...",
filterByType: "유형별로 필터",
showActiveConnectionsOnly: "활성 연결만 표시",
disconnectAllActiveConnections: "모든 활성 연결 끊기",
sortConnections: "연결 정렬",
sortConnectionsManual: "수동 순서",
sortConnectionsAscending: "이름: 가나다순",
@@ -852,6 +853,8 @@ export default withEnglishFallback({
deletedSelected: "연결 {count}개 삭제",
copied: "복사했습니다",
disconnected: "연결이 끊겼습니다",
disconnectedSelected: "연결 {count}개를 끊었습니다",
disconnectSelectedPartial: "연결 {succeeded}개를 끊었고 {failed}개는 실패했습니다",
databaseConnectionClosed: "데이터베이스 연결 닫음: {name}",
duplicated: "연결을 복제했습니다",
duplicatedSelected: "연결 {count}개 복제",
@@ -2011,6 +2014,7 @@ export default withEnglishFallback({
deselectConnection: "연결 선택 해제",
selectAllConnections: "모든 연결 선택",
deselectAllConnections: "모든 연결 선택 해제",
selectedConnections: "{count}개 선택",
exitMultiSelect: "선택 종료",
renameGroup: "그룹 이름 변경",
deleteGroup: "그룹 삭제",
@@ -2396,6 +2400,7 @@ export default withEnglishFallback({
contextMenu: {
openConnection: "연결 열기",
closeConnection: "연결 닫기",
closeSelectedConnections: "선택한 연결 {count}개 닫기",
closeDatabaseConnection: "데이터베이스 연결 닫기",
deleteConnection: "연결 삭제",
deleteSelectedConnections: "선택한 연결 {count}개 삭제",
+5
View File
@@ -319,6 +319,7 @@ export default withEnglishFallback({
showMore: "Mostrar mais {count}...",
filterByType: "Filtrar por tipo",
showActiveConnectionsOnly: "Mostrar apenas conexões ativas",
disconnectAllActiveConnections: "Desconectar todas as conexões ativas",
sortConnections: "Ordenar conexões",
sortConnectionsManual: "Ordem manual",
sortConnectionsAscending: "Nome: A–Z",
@@ -754,6 +755,8 @@ export default withEnglishFallback({
deletedSelected: "{count} conexões excluídas",
copied: "Copiado",
disconnected: "Desconectado",
disconnectedSelected: "{count} conexões desconectadas",
disconnectSelectedPartial: "{succeeded} conexões desconectadas; {failed} falharam",
databaseConnectionClosed: "Conexão de banco de dados fechada: {name}",
duplicated: "Conexão duplicada",
duplicatedSelected: "{count} conexões duplicadas",
@@ -2090,6 +2093,7 @@ export default withEnglishFallback({
deselectConnection: "Desmarcar conexão",
selectAllConnections: "Selecionar todas as conexões",
deselectAllConnections: "Desmarcar todas as conexões",
selectedConnections: "{count} selecionadas",
exitMultiSelect: "Sair da seleção múltipla",
},
databaseSearch: {
@@ -2474,6 +2478,7 @@ export default withEnglishFallback({
contextMenu: {
openConnection: "Abrir Conexão",
closeConnection: "Fechar Conexão",
closeSelectedConnections: "Fechar {count} conexões selecionadas",
closeDatabaseConnection: "Fechar Conexão do Banco de Dados",
deleteConnection: "Excluir Conexão",
deleteSelectedConnections: "Excluir {count} conexões selecionadas",
+5
View File
@@ -244,6 +244,7 @@ export default withEnglishFallback({
showMore: "加载更多 ({count})...",
filterByType: "按类型筛选",
showActiveConnectionsOnly: "仅显示活跃连接",
disconnectAllActiveConnections: "关闭所有活跃连接",
sortConnections: "连接排序",
sortConnectionsManual: "手动排序",
sortConnectionsAscending: "名称:A–Z",
@@ -884,6 +885,8 @@ export default withEnglishFallback({
deletedSelected: "已删除 {count} 个连接",
copied: "已复制",
disconnected: "已断开连接",
disconnectedSelected: "已断开 {count} 个连接",
disconnectSelectedPartial: "已断开 {succeeded} 个连接,{failed} 个失败",
databaseConnectionClosed: "已关闭数据库连接:{name}",
duplicated: "连接已复制",
duplicatedSelected: "已复制 {count} 个连接",
@@ -2071,6 +2074,7 @@ export default withEnglishFallback({
deselectConnection: "取消选择连接",
selectAllConnections: "全选连接",
deselectAllConnections: "取消全选连接",
selectedConnections: "已选 {count}",
exitMultiSelect: "退出多选",
renameGroup: "重命名分组",
deleteGroup: "删除分组",
@@ -2465,6 +2469,7 @@ export default withEnglishFallback({
contextMenu: {
openConnection: "打开连接",
closeConnection: "关闭连接",
closeSelectedConnections: "关闭选中的 {count} 个连接",
closeDatabaseConnection: "关闭数据库连接",
deleteConnection: "删除连接",
deleteSelectedConnections: "删除选中的 {count} 个连接",
+5
View File
@@ -319,6 +319,7 @@ export default withEnglishFallback({
showMore: "再顯示 {count} 個……",
filterByType: "依類型篩選",
showActiveConnectionsOnly: "僅顯示作用中連線",
disconnectAllActiveConnections: "關閉所有作用中連線",
sortConnections: "連線排序",
sortConnectionsManual: "手動排序",
sortConnectionsAscending: "名稱:A–Z",
@@ -753,6 +754,8 @@ export default withEnglishFallback({
deletedSelected: "已刪除 {count} 個連線",
copied: "已複製",
disconnected: "已斷開連線",
disconnectedSelected: "已斷開 {count} 個連線",
disconnectSelectedPartial: "已斷開 {succeeded} 個連線,{failed} 個失敗",
databaseConnectionClosed: "已關閉資料庫連線:{name}",
duplicated: "連線已複製",
duplicatedSelected: "已複製 {count} 個連線",
@@ -2089,6 +2092,7 @@ export default withEnglishFallback({
deselectConnection: "取消選擇連接",
selectAllConnections: "全選連接",
deselectAllConnections: "取消全選連接",
selectedConnections: "已選 {count}",
exitMultiSelect: "退出多選",
},
databaseSearch: {
@@ -2473,6 +2477,7 @@ export default withEnglishFallback({
contextMenu: {
openConnection: "開啟連線",
closeConnection: "關閉連線",
closeSelectedConnections: "關閉選取的 {count} 個連線",
closeDatabaseConnection: "關閉資料庫連線",
deleteConnection: "刪除連線",
deleteSelectedConnections: "刪除選取的 {count} 個連線",
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import { disconnectSidebarConnections } from "@/lib/sidebar/sidebarConnectionDisconnect";
describe("sidebar connection disconnect", () => {
it("attempts every connection and reports success", async () => {
const disconnect = vi.fn().mockResolvedValue(undefined);
const result = await disconnectSidebarConnections(["conn-1", "conn-2"], disconnect);
expect(disconnect.mock.calls).toEqual([["conn-1"], ["conn-2"]]);
expect(result).toEqual({ succeeded: 2, failed: 0, firstError: undefined });
});
it("continues after a failure and retains the first error", async () => {
const firstError = new Error("first failed");
const disconnect = vi.fn().mockRejectedValueOnce(firstError).mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error("last failed"));
const result = await disconnectSidebarConnections(["conn-1", "conn-2", "conn-3"], disconnect);
expect(disconnect).toHaveBeenCalledTimes(3);
expect(result).toEqual({ succeeded: 1, failed: 2, firstError });
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { applyConnectionMultiSelection, connectionMultiSelectionAfterToggle, emptyConnectionMultiSelection, releaseConnectionFromMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { applyConnectionMultiSelection, applyTreeNodeSelection, connectionMultiSelectionAfterToggle, emptyConnectionMultiSelection, isExitConnectionMultiSelectionShortcut, releaseConnectionFromMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import type { ConnectionMultiSelection, ConnectionMultiSelectionTarget } from "@/lib/sidebar/sidebarConnectionMultiSelect";
function tick(selection: ConnectionMultiSelection, ...connectionIds: string[]): ConnectionMultiSelection {
@@ -11,6 +11,21 @@ function target(): ConnectionMultiSelectionTarget {
}
describe("connection multi-selection", () => {
it("uses plain Escape to exit connection multi-select", () => {
const event = (key: string, modifiers: Partial<Pick<KeyboardEvent, "metaKey" | "ctrlKey" | "altKey" | "shiftKey">> = {}) => ({
key,
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
...modifiers,
});
expect(isExitConnectionMultiSelectionShortcut(event("Escape"))).toBe(true);
expect(isExitConnectionMultiSelectionShortcut(event("Escape", { metaKey: true }))).toBe(false);
expect(isExitConnectionMultiSelectionShortcut(event("Enter"))).toBe(false);
});
it("adds each ticked connection to the running selection", () => {
const selection = tick(emptyConnectionMultiSelection(), "conn-1", "conn-2", "conn-3");
@@ -49,6 +64,24 @@ describe("connection multi-selection", () => {
});
});
it("enables checkbox mode for a modifier-key selection containing only connections", () => {
const store = target();
applyTreeNodeSelection(store, { nodeIds: ["conn-1", "conn-2"], activeNodeId: "conn-2", anchorNodeId: "conn-2" }, new Set(["conn-1", "conn-2"]));
expect(store.selectedTreeNodeIds).toEqual(["conn-1", "conn-2"]);
expect(store.connectionMultiSelectActive).toBe(true);
});
it("keeps mixed modifier-key selections out of connection checkbox mode", () => {
const store = target();
applyTreeNodeSelection(store, { nodeIds: ["conn-1", "database-1"], activeNodeId: "database-1", anchorNodeId: "conn-1" }, new Set(["conn-1", "conn-2"]));
expect(store.selectedTreeNodeIds).toEqual(["conn-1", "database-1"]);
expect(store.connectionMultiSelectActive).toBe(false);
});
it("clears every tree selection field when the selection is released", () => {
const store = target();
applyConnectionMultiSelection(store, tick(emptyConnectionMultiSelection(), "conn-1"));
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { copySelectedConnectionsToClipboards, selectedConnectionDeleteTargets, selectedConnectionDuplicateTargets } from "@/lib/sidebar/sidebarConnectionSelection";
import { copySelectedConnectionsToClipboards, selectedConnectionDeleteTargets, selectedConnectionDisconnectTargets, selectedConnectionDuplicateTargets } from "@/lib/sidebar/sidebarConnectionSelection";
import type { TreeNode } from "@/types/database";
function node(id: string, type: TreeNode["type"] = "connection"): TreeNode {
@@ -31,6 +31,14 @@ describe("sidebar connection selection", () => {
const selected = [current, node("conn-2")];
expect(selectedConnectionDuplicateTargets(current, selected)).toEqual(selectedConnectionDeleteTargets(current, selected));
expect(selectedConnectionDisconnectTargets(current, selected)).toEqual(selectedConnectionDeleteTargets(current, selected));
});
it("disconnects only the right-clicked connection when it is outside the selection", () => {
const current = node("conn-3");
const selected = [node("conn-1"), node("conn-2")];
expect(selectedConnectionDisconnectTargets(current, selected).map((item) => item.connectionId)).toEqual(["conn-3"]);
});
it("copies selected connection names to the system clipboard for the sidebar copy shortcut", () => {
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { filterSidebarModifierSelectionIds, selectedSidebarBatchTargets, supportsSidebarModifierSelection } from "@/lib/sidebar/sidebarTreeSelection";
import type { TreeNode } from "@/types/database";
function node(id: string, type: TreeNode["type"]): TreeNode {
return { id, label: id, type };
}
describe("sidebar modifier selection", () => {
it("disables modifier selection for database, schema, and object-group nodes", () => {
expect(supportsSidebarModifierSelection(node("database-1", "database"))).toBe(false);
expect(supportsSidebarModifierSelection(node("schema-1", "schema"))).toBe(false);
expect(supportsSidebarModifierSelection(node("tables-1", "group-tables"))).toBe(false);
expect(supportsSidebarModifierSelection(node("views-1", "group-views"))).toBe(false);
});
it("keeps nodes with existing connection and object bulk actions selectable", () => {
expect(supportsSidebarModifierSelection(node("connection-1", "connection"))).toBe(true);
expect(supportsSidebarModifierSelection(node("table-1", "table"))).toBe(true);
expect(supportsSidebarModifierSelection(node("index-1", "index"))).toBe(true);
});
it("excludes containers reached indirectly by a modifier selection", () => {
const nodes = [node("tables-1", "group-tables"), node("table-1", "table"), node("schema-1", "schema"), node("database-1", "database"), node("table-2", "table")];
expect(
filterSidebarModifierSelectionIds(
nodes,
nodes.map((item) => item.id),
),
).toEqual(["table-1", "table-2"]);
});
});
describe("sidebar batch action targets", () => {
const table = (id: string, connectionId = "connection-1", database = "database-1"): TreeNode => ({
id,
label: id,
type: "table",
connectionId,
database,
});
it("keeps selected tables from the same database when the context target is selected", () => {
const first = table("table-1");
const second = table("table-2");
expect(selectedSidebarBatchTargets(second, [first, second], () => true)).toEqual([first, second]);
});
it("rejects stale, cross-database, and unsupported selections", () => {
const first = table("table-1");
const second = table("table-2");
expect(selectedSidebarBatchTargets(table("outside"), [first, second], () => true)).toEqual([]);
expect(selectedSidebarBatchTargets(second, [first, table("other", "connection-1", "database-2")], () => true)).toEqual([]);
expect(selectedSidebarBatchTargets(second, [first, second], (target) => target.id !== second.id)).toEqual([]);
});
});
@@ -0,0 +1,16 @@
export interface SidebarConnectionDisconnectResult {
succeeded: number;
failed: number;
firstError?: unknown;
}
export async function disconnectSidebarConnections(connectionIds: readonly string[], disconnect: (connectionId: string) => Promise<unknown>): Promise<SidebarConnectionDisconnectResult> {
const results = await Promise.allSettled(connectionIds.map((connectionId) => disconnect(connectionId)));
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
return {
succeeded: results.length - failures.length,
failed: failures.length,
firstError: failures[0]?.reason,
};
}
@@ -21,6 +21,16 @@ export type ConnectionMultiSelectionTarget = {
connectionMultiSelectActive: boolean;
};
export type TreeNodeSelection = {
nodeIds: string[];
activeNodeId: string | null;
anchorNodeId: string | null;
};
export function isExitConnectionMultiSelectionShortcut(event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">): boolean {
return event.key === "Escape" && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey;
}
export function emptyConnectionMultiSelection(): ConnectionMultiSelection {
return { connectionIds: [], activeConnectionId: null, anchorConnectionId: null, active: false };
}
@@ -51,6 +61,18 @@ export function applyConnectionMultiSelection(target: ConnectionMultiSelectionTa
target.connectionMultiSelectActive = selection.active;
}
/**
* 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.
*/
export function applyTreeNodeSelection(target: ConnectionMultiSelectionTarget, selection: TreeNodeSelection, connectionIds: ReadonlySet<string>): 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));
}
export function releaseConnectionFromMultiSelection(target: ConnectionMultiSelectionTarget, connectionId: string): void {
if (!target.connectionMultiSelectActive || !target.selectedTreeNodeIds.includes(connectionId)) return;
@@ -24,6 +24,10 @@ export function selectedConnectionDuplicateTargets(currentNode: TreeNode, select
return selectedConnectionActionTargets(currentNode, selectedNodes);
}
export function selectedConnectionDisconnectTargets(currentNode: TreeNode, selectedNodes: TreeNode[]): ConnectionTreeNode[] {
return selectedConnectionActionTargets(currentNode, selectedNodes);
}
export function selectedConnectionClipboardTargets(currentNode: TreeNode, selectedNodes: TreeNode[]): ConnectionTreeNode[] {
return selectedConnectionActionTargets(currentNode, selectedNodes);
}
@@ -1,11 +1,28 @@
import type { TreeNode } from "@/types/database";
export function supportsSidebarModifierSelection(node: Pick<TreeNode, "type">): boolean {
return node.type !== "database" && node.type !== "schema" && !node.type.startsWith("group-");
}
export function filterSidebarModifierSelectionIds(nodes: readonly TreeNode[], nodeIds: readonly string[]): string[] {
const selectableIds = new Set(nodes.filter(supportsSidebarModifierSelection).map((node) => node.id));
return nodeIds.filter((id) => selectableIds.has(id));
}
export function selectedTreeNodesInVisibleOrder(visibleNodes: TreeNode[], selectedIds: Iterable<string>): TreeNode[] {
const ids = new Set(selectedIds);
if (!ids.size) return [];
return visibleNodes.filter((node) => ids.has(node.id));
}
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];
if (!first?.connectionId || !first.database || !selectedNodes.every((node) => node.type === first.type)) return [];
if (!selectedNodes.every((node) => node.connectionId === first.connectionId && node.database === first.database && canUseTarget(node))) return [];
return [...selectedNodes];
}
export function treeSelectionRangeIdsByIndex(visibleNodes: TreeNode[], currentIndex: number, anchorIndex: number, currentId?: string): string[] {
if (anchorIndex < 0 || currentIndex < 0) return currentId ? [currentId] : [];
const start = Math.min(anchorIndex, currentIndex);