fix(sidebar): improve connection multi-select actions
This commit is contained in:
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[] {
|
||||
|
||||
+32
@@ -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",
|
||||
|
||||
Vendored
+67
-3
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}件の接続を削除",
|
||||
|
||||
@@ -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}개 삭제",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 个连接",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
+34
-1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user