diff --git a/apps/desktop/src/components/layout/AppSidebar.connectionMultiSelect.spec.ts b/apps/desktop/src/components/layout/AppSidebar.connectionMultiSelect.spec.ts new file mode 100644 index 0000000000..ade7b1fb5b --- /dev/null +++ b/apps/desktop/src/components/layout/AppSidebar.connectionMultiSelect.spec.ts @@ -0,0 +1,261 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createApp, defineComponent, h, nextTick, reactive } from "vue"; + +const mocks = vi.hoisted(() => ({ + store: null as any, + toast: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => mocks.store, +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast: mocks.toast }), +})); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + inheritAttrs: false, + props: { disabled: Boolean }, + setup(props, { attrs, slots }) { + return () => h("button", { ...attrs, disabled: props.disabled }, slots.default?.()); + }, + }), + }; +}); + +vi.mock("@/components/ui/dialog", async () => { + const { defineComponent, h } = await import("vue"); + const passthrough = (name: string) => + defineComponent({ + name, + setup(_, { slots }) { + return () => h("div", { [`data-${name.toLowerCase()}`]: "" }, slots.default?.()); + }, + }); + return { + Dialog: defineComponent({ + props: { open: Boolean }, + emits: ["update:open"], + setup(props, { slots }) { + return () => (props.open ? h("div", { "data-dialog": "" }, slots.default?.()) : null); + }, + }), + DialogContent: passthrough("DialogContent"), + DialogFooter: passthrough("DialogFooter"), + DialogHeader: passthrough("DialogHeader"), + DialogTitle: passthrough("DialogTitle"), + }; +}); + +vi.mock("@/components/ui/input", async () => { + const { defineComponent, h } = await import("vue"); + return { + Input: defineComponent({ + inheritAttrs: false, + props: { modelValue: { type: String, default: "" } }, + emits: ["update:modelValue"], + setup(props, { attrs, emit }) { + return () => + h("input", { + ...attrs, + value: props.modelValue, + onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value), + }); + }, + }), + }; +}); + +vi.mock("@/components/ui/LightTooltip.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + props: { text: { type: String, default: "" } }, + setup(props, { slots }) { + return () => h("span", { "data-tooltip": props.text }, slots.default?.()); + }, + }), + }; +}); + +vi.mock("@/components/ui/LightDropdown.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + props: { + items: { type: Array, default: () => [] }, + ariaLabel: { type: String, default: "" }, + }, + emits: ["update:modelValue"], + setup(props, { emit }) { + return () => + h( + "div", + { "data-dropdown": props.ariaLabel }, + (props.items as Array<{ value?: string; val?: string; label: string }>).map((item) => { + const value = item.value ?? item.val ?? ""; + return h("button", { "data-dropdown-value": value, onClick: () => emit("update:modelValue", value) }, item.label); + }), + ); + }, + }), + }; +}); + +vi.mock("@/components/sidebar/ConnectionTree.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + setup(_, { expose }) { + expose({ + createNewGroup: vi.fn(), + collapseAllTreeNodes: vi.fn(), + focusSearch: vi.fn(() => false), + }); + return () => h("div", { "data-connection-tree": "" }); + }, + }), + }; +}); + +import AppSidebar from "@/components/layout/AppSidebar.vue"; + +const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = []; + +function createStore() { + return reactive({ + connections: [{ id: "conn-visible" }, { id: "conn-hidden" }, { id: "conn-next" }], + sidebarLayout: { groups: [{ id: "group-a", name: "Group A" }] }, + selectedTreeNodeIds: [] as string[], + selectedTreeNodeId: null as string | null, + treeSelectionAnchorId: null as string | null, + connectionMultiSelectActive: false, + moveConnectionToGroup: vi.fn(), + createConnectionGroup: vi.fn(() => "group-new"), + refreshAllTree: vi.fn().mockResolvedValue(undefined), + removeConnections: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + }); +} + +function setSelection(connectionIds: string[]) { + const lastConnectionId = connectionIds[connectionIds.length - 1] ?? null; + mocks.store.selectedTreeNodeIds = [...connectionIds]; + mocks.store.selectedTreeNodeId = lastConnectionId; + mocks.store.treeSelectionAnchorId = lastConnectionId; + mocks.store.connectionMultiSelectActive = connectionIds.length > 0; +} + +function click(element: Element | null) { + expect(element).not.toBeNull(); + element!.dispatchEvent(new MouseEvent("click", { bubbles: true })); +} + +async function mountSidebar() { + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp( + defineComponent({ + setup: () => () => h(AppSidebar, { sidebarWidth: 260 }), + }), + ); + app.mount(host); + mountedApps.push({ unmount: () => app.unmount(), host }); + await nextTick(); + return host; +} + +describe("AppSidebar connection multi-select moves", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.store = createStore(); + }); + + afterEach(() => { + for (const mounted of mountedApps.splice(0)) { + mounted.unmount(); + mounted.host.remove(); + } + }); + + it("releases an existing-group batch including filtered connections before the next batch", async () => { + setSelection(["conn-visible", "conn-hidden"]); + const host = await mountSidebar(); + + click(host.querySelector('[data-dropdown="connectionGroup.moveToGroup"] [data-dropdown-value="group-a"]')); + await nextTick(); + + expect(mocks.store.moveConnectionToGroup.mock.calls).toEqual([ + ["conn-visible", "group-a"], + ["conn-hidden", "group-a"], + ]); + expect(mocks.store.selectedTreeNodeIds).toEqual([]); + expect(mocks.store.connectionMultiSelectActive).toBe(false); + + setSelection(["conn-next"]); + await nextTick(); + click(host.querySelector('[data-dropdown="connectionGroup.moveToGroup"] [data-dropdown-value="group-a"]')); + await nextTick(); + + expect(mocks.store.moveConnectionToGroup.mock.calls[mocks.store.moveConnectionToGroup.mock.calls.length - 1]).toEqual(["conn-next", "group-a"]); + expect(mocks.store.moveConnectionToGroup).toHaveBeenCalledTimes(3); + expect(mocks.store.selectedTreeNodeIds).toEqual([]); + }); + + it("keeps selection on cancel and releases it after creating a new group", async () => { + setSelection(["conn-visible", "conn-hidden"]); + const host = await mountSidebar(); + + click(host.querySelector('[data-tooltip="connectionGroup.createGroup"] button')); + await nextTick(); + click(Array.from(host.querySelectorAll("[data-dialog] button")).find((button) => button.textContent === "dangerDialog.cancel") ?? null); + await nextTick(); + + expect(mocks.store.createConnectionGroup).not.toHaveBeenCalled(); + expect(mocks.store.moveConnectionToGroup).not.toHaveBeenCalled(); + expect(mocks.store.selectedTreeNodeIds).toEqual(["conn-visible", "conn-hidden"]); + + click(host.querySelector('[data-tooltip="connectionGroup.createGroup"] button')); + await nextTick(); + const input = host.querySelector("[data-dialog] input"); + expect(input).not.toBeNull(); + input!.value = "New group"; + input!.dispatchEvent(new Event("input", { bubbles: true })); + await nextTick(); + click(Array.from(host.querySelectorAll("[data-dialog] button")).find((button) => button.textContent === "connectionGroup.createGroup") ?? null); + await nextTick(); + + expect(mocks.store.createConnectionGroup).toHaveBeenCalledWith("New group"); + expect(mocks.store.moveConnectionToGroup.mock.calls).toEqual([ + ["conn-visible", "group-new"], + ["conn-hidden", "group-new"], + ]); + expect(mocks.store.selectedTreeNodeIds).toEqual([]); + expect(mocks.store.connectionMultiSelectActive).toBe(false); + }); + + it("releases a batch moved to ungrouped", async () => { + setSelection(["conn-visible", "conn-hidden"]); + const host = await mountSidebar(); + + click(host.querySelector('[data-dropdown="connectionGroup.moveToGroup"] [data-dropdown-value="__ungrouped"]')); + await nextTick(); + + expect(mocks.store.moveConnectionToGroup.mock.calls).toEqual([ + ["conn-visible", null], + ["conn-hidden", null], + ]); + expect(mocks.store.selectedTreeNodeIds).toEqual([]); + expect(mocks.store.connectionMultiSelectActive).toBe(false); + }); +}); diff --git a/apps/desktop/src/components/layout/AppSidebar.vue b/apps/desktop/src/components/layout/AppSidebar.vue index def69476bb..a90e4e220b 100644 --- a/apps/desktop/src/components/layout/AppSidebar.vue +++ b/apps/desktop/src/components/layout/AppSidebar.vue @@ -9,6 +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 { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; @@ -86,10 +87,7 @@ function focusSearch(): boolean { } function clearConnectionMultiSelection() { - connectionStore.connectionMultiSelectActive = false; - connectionStore.selectedTreeNodeIds = []; - connectionStore.selectedTreeNodeId = null; - connectionStore.treeSelectionAnchorId = null; + applyConnectionMultiSelection(connectionStore, emptyConnectionMultiSelection()); } function toggleAllConnectionsSelected() { @@ -125,9 +123,13 @@ async function confirmDeleteSelectedConnections() { function moveSelectedConnectionsToGroup(value: string) { const groupId = value === UNGROUPED_GROUP_VALUE ? null : value; - for (const connectionId of selectedConnectionIds.value) { + const ids = selectedConnectionIds.value; + for (const connectionId of ids) { connectionStore.moveConnectionToGroup(connectionId, groupId); } + // The moved connections stay selected otherwise, so the next batch would be + // moved together with them (issue #5758). + clearConnectionMultiSelection(); } function openCreateSelectedGroupDialog() { @@ -137,11 +139,13 @@ function openCreateSelectedGroupDialog() { function confirmCreateSelectedGroup() { const name = selectedGroupName.value.trim(); - if (!name || selectedConnectionIds.value.length === 0) return; + const ids = selectedConnectionIds.value; + if (!name || ids.length === 0) return; const groupId = connectionStore.createConnectionGroup(name); - for (const connectionId of selectedConnectionIds.value) { + for (const connectionId of ids) { connectionStore.moveConnectionToGroup(connectionId, groupId); } + clearConnectionMultiSelection(); showCreateSelectedGroupDialog.value = false; } diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 1e23330ea5..eab19736b4 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -385,6 +385,7 @@ const { newSubgroup, confirmDeleteGroup, moveToGroup, + createGroupAndMoveConnection, } = useSidebarConnectionMutationRuntime({ activeNode, releaseActiveNodeReference, @@ -3799,12 +3800,7 @@ function moveToNewGroup() { } function confirmMoveToNewGroup() { - const name = moveToNewGroupName.value.trim(); - const node = sidebarFormTarget.value ?? activeNode.value; - if (name && node.connectionId) { - const groupId = connectionStore.createConnectionGroup(name); - connectionStore.moveConnectionToGroup(node.connectionId, groupId); - } + createGroupAndMoveConnection(moveToNewGroupName.value); showMoveToNewGroupDialog.value = false; } diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 66a6840ff7..0f3287dbbe 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -60,6 +60,7 @@ 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 { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu"; @@ -568,15 +569,8 @@ function toggleConnectionMultiSelection(event: MouseEvent) { // Keep connection-id normalization off the row render path; this handler only // runs when the checkbox is clicked, while the checked state updates often. - const next = new Set(connectionStore.connectionMultiSelectActive ? selectedConnectionIdsForAction() : []); - if (next.has(activeNode.value.connectionId)) next.delete(activeNode.value.connectionId); - else next.add(activeNode.value.connectionId); - - const ids = [...next]; - connectionStore.selectedTreeNodeIds = ids; - connectionStore.selectedTreeNodeId = ids.includes(activeNode.value.connectionId) ? activeNode.value.connectionId : (ids[0] ?? null); - connectionStore.treeSelectionAnchorId = activeNode.value.connectionId; - connectionStore.connectionMultiSelectActive = ids.length > 0; + const current = { connectionIds: selectedConnectionIdsForAction(), active: connectionStore.connectionMultiSelectActive }; + applyConnectionMultiSelection(connectionStore, connectionMultiSelectionAfterToggle(current, activeNode.value.connectionId)); rowRef.value?.focus({ preventScroll: true }); } diff --git a/apps/desktop/src/composables/__tests__/useSidebarConnectionMutationRuntime.selection.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarConnectionMutationRuntime.selection.spec.ts new file mode 100644 index 0000000000..7ba4dbe6d3 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useSidebarConnectionMutationRuntime.selection.spec.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { shallowRef } from "vue"; +import { sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState"; +import type { TreeNode } from "@/types/database"; + +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast: mocks.toast }), +})); + +import { useSidebarConnectionMutationRuntime } from "@/composables/useSidebarConnectionMutationRuntime"; + +function connectionNode(connectionId: string): TreeNode { + return { + id: connectionId, + label: connectionId, + type: "connection", + connectionId, + isExpanded: false, + children: [], + }; +} + +function connectionStore(selectedTreeNodeIds: string[]) { + const lastSelectedTreeNodeId = selectedTreeNodeIds[selectedTreeNodeIds.length - 1] ?? null; + return { + selectedTreeNodeIds: [...selectedTreeNodeIds], + selectedTreeNodeId: lastSelectedTreeNodeId, + treeSelectionAnchorId: lastSelectedTreeNodeId, + connectionMultiSelectActive: selectedTreeNodeIds.length > 0, + moveConnectionToGroup: vi.fn(), + createConnectionGroup: vi.fn(() => "group-new"), + connectedIds: new Set(), + connectingIds: new Set(), + isTreeNodeChildrenLoaded: vi.fn(() => false), + getConfig: vi.fn(() => undefined), + }; +} + +function runtime(activeNode: TreeNode, store: ReturnType) { + return useSidebarConnectionMutationRuntime({ + activeNode: shallowRef(activeNode), + releaseActiveNodeReference: vi.fn(), + selectedTreeNodesInVisibleOrder: () => [], + connectionStore: store as any, + queryStore: { openDatabaseKeys: new Set() } as any, + requestGroupRename: vi.fn(), + groupCreated: vi.fn(), + openVisibleDatabases: vi.fn(), + openVisibleSchemas: vi.fn(), + }); +} + +describe("sidebar connection move selection", () => { + beforeEach(() => { + vi.clearAllMocks(); + sidebarFormTarget.value = null; + }); + + it("removes only the right-clicked connection after moving to an existing group", () => { + const store = connectionStore(["conn-hidden", "conn-active"]); + const { moveToGroup } = runtime(connectionNode("conn-active"), store); + + moveToGroup("group-a"); + + expect(store.moveConnectionToGroup).toHaveBeenCalledWith("conn-active", "group-a"); + expect(store.selectedTreeNodeIds).toEqual(["conn-hidden"]); + expect(store.selectedTreeNodeId).toBe("conn-hidden"); + expect(store.treeSelectionAnchorId).toBe("conn-hidden"); + expect(store.connectionMultiSelectActive).toBe(true); + }); + + it("releases the final selected connection after moving it to ungrouped", () => { + const store = connectionStore(["conn-active"]); + const { moveToGroup } = runtime(connectionNode("conn-active"), store); + + moveToGroup(null); + + expect(store.moveConnectionToGroup).toHaveBeenCalledWith("conn-active", null); + expect(store.selectedTreeNodeIds).toEqual([]); + expect(store.selectedTreeNodeId).toBeNull(); + expect(store.treeSelectionAnchorId).toBeNull(); + expect(store.connectionMultiSelectActive).toBe(false); + }); + + it("uses the dialog target when creating a group and preserves filtered selections", () => { + const store = connectionStore(["conn-dialog", "conn-hidden"]); + sidebarFormTarget.value = connectionNode("conn-dialog"); + const { createGroupAndMoveConnection } = runtime(connectionNode("conn-current"), store); + + expect(createGroupAndMoveConnection(" New group ")).toBe(true); + + expect(store.createConnectionGroup).toHaveBeenCalledWith("New group"); + expect(store.moveConnectionToGroup).toHaveBeenCalledWith("conn-dialog", "group-new"); + expect(store.selectedTreeNodeIds).toEqual(["conn-hidden"]); + expect(store.connectionMultiSelectActive).toBe(true); + }); + + it("keeps the selection when a new-group move is cancelled", () => { + const store = connectionStore(["conn-active", "conn-hidden"]); + const { createGroupAndMoveConnection } = runtime(connectionNode("conn-active"), store); + + expect(createGroupAndMoveConnection(" ")).toBe(false); + + expect(store.createConnectionGroup).not.toHaveBeenCalled(); + expect(store.moveConnectionToGroup).not.toHaveBeenCalled(); + expect(store.selectedTreeNodeIds).toEqual(["conn-active", "conn-hidden"]); + expect(store.connectionMultiSelectActive).toBe(true); + }); + + it("does not release the connection when the move fails", () => { + const store = connectionStore(["conn-active", "conn-hidden"]); + store.moveConnectionToGroup.mockImplementation(() => { + throw new Error("move failed"); + }); + const { moveToGroup } = runtime(connectionNode("conn-active"), store); + + expect(() => moveToGroup("group-a")).toThrow("move failed"); + expect(store.selectedTreeNodeIds).toEqual(["conn-active", "conn-hidden"]); + expect(store.connectionMultiSelectActive).toBe(true); + }); +}); diff --git a/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts b/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts index 6e3ccaf254..ec8d080124 100644 --- a/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts +++ b/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts @@ -15,6 +15,7 @@ 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 { releaseConnectionFromMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect"; import { connectionDeleteTargetSnapshot, showDeleteConfirm, showDeleteGroupConfirm, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState"; import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu"; @@ -305,7 +306,19 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu function moveToGroup(groupId: string | null) { const connectionId = activeNode.value.connectionId; - if (connectionId) connectionStore.moveConnectionToGroup(connectionId, groupId); + if (!connectionId) return; + connectionStore.moveConnectionToGroup(connectionId, groupId); + releaseConnectionFromMultiSelection(connectionStore, connectionId); + } + + function createGroupAndMoveConnection(name: string): boolean { + const node = sidebarFormTarget.value ?? activeNode.value; + const normalizedName = name.trim(); + if (!normalizedName || !node.connectionId) return false; + const groupId = connectionStore.createConnectionGroup(normalizedName); + connectionStore.moveConnectionToGroup(node.connectionId, groupId); + releaseConnectionFromMultiSelection(connectionStore, node.connectionId); + return true; } return { @@ -349,5 +362,6 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu newSubgroup, confirmDeleteGroup, moveToGroup, + createGroupAndMoveConnection, }; } diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarConnectionMultiSelect.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarConnectionMultiSelect.spec.ts new file mode 100644 index 0000000000..0843a5e3e2 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarConnectionMultiSelect.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { applyConnectionMultiSelection, connectionMultiSelectionAfterToggle, emptyConnectionMultiSelection, releaseConnectionFromMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect"; +import type { ConnectionMultiSelection, ConnectionMultiSelectionTarget } from "@/lib/sidebar/sidebarConnectionMultiSelect"; + +function tick(selection: ConnectionMultiSelection, ...connectionIds: string[]): ConnectionMultiSelection { + return connectionIds.reduce(connectionMultiSelectionAfterToggle, selection); +} + +function target(): ConnectionMultiSelectionTarget { + return { selectedTreeNodeIds: [], selectedTreeNodeId: null, treeSelectionAnchorId: null, connectionMultiSelectActive: false }; +} + +describe("connection multi-selection", () => { + it("adds each ticked connection to the running selection", () => { + const selection = tick(emptyConnectionMultiSelection(), "conn-1", "conn-2", "conn-3"); + + expect(selection.connectionIds).toEqual(["conn-1", "conn-2", "conn-3"]); + expect(selection.activeConnectionId).toBe("conn-3"); + expect(selection.anchorConnectionId).toBe("conn-3"); + expect(selection.active).toBe(true); + }); + + it("drops a connection that is ticked twice", () => { + const selection = tick(emptyConnectionMultiSelection(), "conn-1", "conn-2", "conn-2"); + + expect(selection.connectionIds).toEqual(["conn-1"]); + expect(selection.activeConnectionId).toBe("conn-1"); + expect(selection.active).toBe(true); + }); + + it("leaves multi-select off once the last connection is unticked", () => { + const selection = tick(emptyConnectionMultiSelection(), "conn-1", "conn-1"); + + expect(selection.connectionIds).toEqual([]); + expect(selection.activeConnectionId).toBeNull(); + expect(selection.active).toBe(false); + }); + + it("writes the selection back to the tree selection fields", () => { + const store = target(); + + applyConnectionMultiSelection(store, tick(emptyConnectionMultiSelection(), "conn-1", "conn-2")); + + expect(store).toEqual({ + selectedTreeNodeIds: ["conn-1", "conn-2"], + selectedTreeNodeId: "conn-2", + treeSelectionAnchorId: "conn-2", + connectionMultiSelectActive: true, + }); + }); + + it("clears every tree selection field when the selection is released", () => { + const store = target(); + applyConnectionMultiSelection(store, tick(emptyConnectionMultiSelection(), "conn-1")); + + applyConnectionMultiSelection(store, emptyConnectionMultiSelection()); + + expect(store).toEqual({ + selectedTreeNodeIds: [], + selectedTreeNodeId: null, + treeSelectionAnchorId: null, + connectionMultiSelectActive: false, + }); + }); + + it("releases only the connection that was moved from a multi-selection", () => { + const store = target(); + applyConnectionMultiSelection(store, tick(emptyConnectionMultiSelection(), "conn-1", "conn-2", "conn-3")); + + releaseConnectionFromMultiSelection(store, "conn-3"); + + expect(store).toEqual({ + selectedTreeNodeIds: ["conn-1", "conn-2"], + selectedTreeNodeId: "conn-1", + treeSelectionAnchorId: "conn-1", + connectionMultiSelectActive: true, + }); + }); + + it("leaves an unrelated multi-selection unchanged", () => { + const store = target(); + applyConnectionMultiSelection(store, tick(emptyConnectionMultiSelection(), "conn-1", "conn-2")); + + releaseConnectionFromMultiSelection(store, "conn-3"); + + expect(store.selectedTreeNodeIds).toEqual(["conn-1", "conn-2"]); + expect(store.connectionMultiSelectActive).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/sidebarConnectionMultiSelect.ts b/apps/desktop/src/lib/sidebar/sidebarConnectionMultiSelect.ts new file mode 100644 index 0000000000..cd45691269 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/sidebarConnectionMultiSelect.ts @@ -0,0 +1,66 @@ +/** + * Connection multi-selection state for the sidebar tree. + * + * The transitions live here rather than in the components because the checkbox + * that grows the selection and the bulk actions that release it sit in two + * different files, and only their combination decides what the next bulk action + * operates on. + */ +export type ConnectionMultiSelection = { + connectionIds: string[]; + activeConnectionId: string | null; + anchorConnectionId: string | null; + active: boolean; +}; + +/** The tree selection fields a multi-selection is written back to. */ +export type ConnectionMultiSelectionTarget = { + selectedTreeNodeIds: string[]; + selectedTreeNodeId: string | null; + treeSelectionAnchorId: string | null; + connectionMultiSelectActive: boolean; +}; + +export function emptyConnectionMultiSelection(): ConnectionMultiSelection { + return { connectionIds: [], activeConnectionId: null, anchorConnectionId: null, active: false }; +} + +/** + * Ticking a checkbox adds to the current selection while multi-select is on, and + * starts a new one when it is off. A bulk action that forgets to release the + * selection therefore hands its connections to the next one (issue #5758). + */ +export function connectionMultiSelectionAfterToggle(selection: Pick, connectionId: string): ConnectionMultiSelection { + const next = new Set(selection.active ? selection.connectionIds : []); + if (next.has(connectionId)) next.delete(connectionId); + else next.add(connectionId); + + const connectionIds = [...next]; + return { + connectionIds, + activeConnectionId: next.has(connectionId) ? connectionId : (connectionIds[0] ?? null), + anchorConnectionId: connectionId, + active: connectionIds.length > 0, + }; +} + +export function applyConnectionMultiSelection(target: ConnectionMultiSelectionTarget, selection: ConnectionMultiSelection): void { + target.selectedTreeNodeIds = selection.connectionIds; + target.selectedTreeNodeId = selection.activeConnectionId; + target.treeSelectionAnchorId = selection.anchorConnectionId; + target.connectionMultiSelectActive = selection.active; +} + +export function releaseConnectionFromMultiSelection(target: ConnectionMultiSelectionTarget, connectionId: string): void { + if (!target.connectionMultiSelectActive || !target.selectedTreeNodeIds.includes(connectionId)) return; + + const connectionIds = target.selectedTreeNodeIds.filter((id) => id !== connectionId); + const activeConnectionId = target.selectedTreeNodeId && connectionIds.includes(target.selectedTreeNodeId) ? target.selectedTreeNodeId : (connectionIds[0] ?? null); + const anchorConnectionId = target.treeSelectionAnchorId && connectionIds.includes(target.treeSelectionAnchorId) ? target.treeSelectionAnchorId : activeConnectionId; + applyConnectionMultiSelection(target, { + connectionIds, + activeConnectionId, + anchorConnectionId, + active: connectionIds.length > 0, + }); +}